2025-07-01

This commit is contained in:
2026-03-17 14:30:01 -06:00
parent f9a22056dd
commit 62b5978595
4579 changed files with 1257472 additions and 0 deletions
@@ -0,0 +1,17 @@
# #### 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 #####
@@ -0,0 +1,107 @@
# #### 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 bpy.types import Operator
from bpy.props import StringProperty
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_active(Operator):
bl_idname = "poliigon.poliigon_active"
bl_label = ""
bl_description = _t("Set Active Asset")
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"REGISTER", "INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
mode: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_type: StringProperty(options={"HIDDEN"}) # noqa: F821
data: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@reporting.handle_operator()
def execute(self, context):
global cTB
if self.data == "":
cTB.vActiveType = None
cTB.vActiveAsset = None
cTB.vActiveMat = None
cTB.vActiveMode = None
elif self.mode == "asset":
cTB.vActiveType = self.asset_type
cTB.vActiveAsset = self.data
if cTB.vActiveAsset in cTB.imported_assets["Textures"].keys():
cTB.vActiveMat = cTB.imported_assets["Textures"][cTB.vActiveAsset][0].name
context.scene.vEditMatName = cTB.vActiveMat
else:
cTB.vActiveMat = None
cTB.vActiveMode = "asset"
cTB.settings["show_active"] = 1
elif self.mode == "mat":
# TODO(Andreas): This seems to be the only case used from this op.
cTB.vActiveType = self.asset_type
if "@" in self.data:
cTB.vActiveAsset, cTB.vActiveMat = self.data.split("@")
else:
cTB.vActiveMat = self.data
cTB.vActiveMode = "asset"
elif self.mode == "mixer":
cTB.vActiveType = self.asset_type
cTB.vActiveAsset = self.data
context.scene.vEditMatName = cTB.vActiveAsset
cTB.vActiveMat = self.data
cTB.vActiveMode = "mixer"
cTB.settings["show_active"] = 1
elif self.mode == "mix":
cTB.vActiveMode = "mixer"
cTB.vActiveMix = self.data
elif self.mode == "mixmat":
cTB.vActiveMode = "mixer"
cTB.vActiveMixMat = self.data
elif self.mode == "poliigon":
cTB.vActiveMode = "poliigon"
cTB.vActiveAsset = self.data
elif self.mode == "settings":
# f_Settings()
return {"FINISHED"}
cTB.f_GetActiveData()
return {"FINISHED"}
@@ -0,0 +1,114 @@
# #### 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
from bpy.types import Operator
from bpy.props import EnumProperty
from ..modules.poliigon_core.multilingual import _t
from ..material_import_utils import load_poliigon_node_group
from ..toolbox import get_context
from .. import reporting
# These need to be global to work in _fill_node_drop_down()
ENUM29 = (
("Poliigon_Mixer",
_t("Principled mixer"),
_t("Principled mixer node")),
("Mosaic_UV_Mapping",
_t("Mosaic mapping"),
_t("Poliigon Mosaic mapping node")),
)
ENUM28 = (
("Poliigon_Mixer",
_t("Principled mixer"),
_t("Principled mixer node")),
)
# Needs to be global,
# as member variable can not be accessed in "items" function of EnumProperty
view_screen_tracked_nodes = False
class POLIIGON_OT_add_converter_node(Operator):
bl_idname = "poliigon.add_converter_node"
bl_label = _t("Converter node group")
bl_description = _t("Adds a material converter node group")
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
def _fill_node_drop_down(self, context):
"""Returns list of available nodes as EnumPropertyItems.
While the enums are actually static, this function serves as a
"draw detection" to track view screen.
"""
# Called during class construction, we can not access a
# member variable here
global view_screen_tracked_nodes
if not view_screen_tracked_nodes:
cTB.track_screen("blend_node_add")
view_screen_tracked_nodes = True
if bpy.app.version >= (2, 90):
return ENUM29
else:
return ENUM28
node_type: EnumProperty(items=_fill_node_drop_down)
@reporting.handle_operator(silent=True)
def execute(self, context):
if not self.node_type:
self.report(
{"Error"}, _t("No node_type specified to add"))
return {'CANCELLED'}
if not context.material:
self.report(
{"ERROR"}, _t("No active material selected to add nodegroup"))
return {"CANCELLED"}
for node in context.material.node_tree.nodes:
node.select = False
# TODO(Andreas): Not supposed to stay in legacy importer module
node_group = load_poliigon_node_group(self.node_type)
if node_group is None:
self.report({"ERROR"}, _t("Failed to import nodegroup."))
return {"CANCELLED"}
mat = context.material
node_mosaic = mat.node_tree.nodes.new("ShaderNodeGroup")
node_mosaic.node_tree = node_group
node_mosaic.name = node_group.name
node_mosaic.width = 200
if not node_mosaic.node_tree:
self.report({"ERROR"}, _t("Failed to load nodegroup."))
return {"CANCELLED"}
# Use this built in modal for moving the added node around
return bpy.ops.node.translate_attach('INVOKE_DEFAULT')
@@ -0,0 +1,196 @@
# #### 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 typing import List
from bpy.types import Operator
from bpy.props import (
IntProperty,
StringProperty)
import bpy.utils.previews
import bmesh
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
SCALE_UNIT_FACTORS = {
"KILOMETERS": 1000.0,
"CENTIMETERS": 1.0 / 100.0,
"MILLIMETERS": 1.0 / 1000.0,
"MILES": 1.0 / 0.000621371,
"FEET": 1.0 / 3.28084,
"INCHES": 1.0 / 39.3701
}
class POLIIGON_OT_apply(Operator):
bl_idname = "poliigon.poliigon_apply"
bl_label = _t("Apply Material :")
bl_description = _t("Apply Material to Selection")
bl_options = {"REGISTER", "INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
name_material: StringProperty(options={"HIDDEN"}) # noqa: F821
def __init__(self, *args, **kwargs):
"""Runs once per operator call before drawing occurs."""
super().__init__(*args, **kwargs)
self.exec_count = 0
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@staticmethod
def get_edit_objects(context) -> List[bpy.types.Object]:
objs_selected = []
for obj in context.scene.objects:
if obj.mode != "EDIT":
continue
mesh = obj.data
b_mesh = bmesh.from_edit_mesh(mesh)
for _face in b_mesh.faces:
if not _face.select:
continue
objs_selected.append(obj)
break
return objs_selected
@reporting.handle_operator()
def execute(self, context):
objs_selected = [_obj for _obj in context.selected_objects]
if len(objs_selected) == 0:
objs_selected = self.get_edit_objects(context)
objs_add_disp = [_obj
for _obj in objs_selected
if "Subdivision" not in _obj.modifiers]
asset_data = cTB._asset_index.get_asset(self.asset_id)
asset_name = asset_data.asset_name
asset_type = asset_data.asset_type
mat = None
if self.name_material != "":
mat = bpy.data.materials[self.name_material]
elif asset_name in cTB.imported_assets["Textures"].keys():
if len(cTB.imported_assets["Textures"][asset_name]) == 1:
mat = cTB.imported_assets["Textures"][asset_name][0]
if mat is None:
# Unexpected need to download local, should avoid happening.
reporting.capture_message(
"triggered_popup_mid_apply", asset_name, level="info")
return {"CANCELLED"}
do_subdiv = False
if cTB.settings["use_disp"] and len(objs_add_disp) > 0:
if cTB.prefs and cTB.prefs.mode_disp == "MICRO":
do_subdiv = True
if do_subdiv or len(objs_selected) > len(objs_add_disp):
bpy.context.scene.render.engine = "CYCLES"
bpy.context.scene.cycles.feature_set = "EXPERIMENTAL"
for _node in mat.node_tree.nodes:
if _node.type != "GROUP":
continue
for _input in _node.inputs:
if _input.type != "VALUE":
continue
elif _input.name == "Displacement Strength":
_node.inputs[_input.name].default_value = mat.poliigon_props.displacement
# TODO(Andreas): Not sure, what this was good for.
# Seems to have done nothing.
# faces_all = []
# for _key in cTB.vActiveFaces.keys():
# faces_all += cTB.vActiveFaces[_key]
valid_objects = 0
for _obj in objs_selected:
if hasattr(_obj.data, "materials"):
valid_objects += 1
else:
continue
if _obj.mode != "EDIT":
_obj.active_material = mat
else:
mats_obj = [_mat.material
for _mat in _obj.material_slots
if _mat is not None]
if mat not in mats_obj:
_obj.data.materials.append(mat)
for idx in range(len(_obj.material_slots)):
if _obj.material_slots[idx].material != mat:
continue
_obj.active_material_index = idx
bpy.ops.object.material_slot_assign()
if do_subdiv and _obj in objs_add_disp:
mod_subdiv = _obj.modifiers.new(
name="Subdivision", type="SUBSURF")
mod_subdiv.subdivision_type = "SIMPLE"
mod_subdiv.levels = 0 # Don't do subdiv in viewport
_obj.cycles.use_adaptive_subdivision = 1
# Scale ...........................................................
# TODO(Andreas): What is this? Did this ever work?
dimension = "?"
if dimension != "?":
scale_mult = bpy.context.scene.unit_settings.scale_length
unit = bpy.context.scene.unit_settings.length_unit
scale_mult *= SCALE_UNIT_FACTORS[unit]
vec_scale = (_obj.scale * scale_mult) / dimension
nodes_mat = mat.node_tree.nodes
for _node in nodes_mat:
if _node.type != "GROUP":
continue
for _input in _node.inputs:
if _input.type != "VALUE":
continue
elif _input.name == "Scale":
_node.inputs[_input.name].default_value = vec_scale[0]
cTB.vActiveType = asset_type.name
cTB.vActiveAsset = asset_name
cTB.vActiveMat = mat.name
bpy.ops.poliigon.poliigon_active(
mode="mat", asset_type=asset_type.name, data=cTB.vActiveMat
)
if self.exec_count == 0:
cTB.signal_import_asset(asset_id=self.asset_id)
self.exec_count += 1
return {"FINISHED"}
@@ -0,0 +1,54 @@
# #### 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 bpy.types import Operator
from bpy.props import IntProperty
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_cancel_download(Operator):
bl_idname = "poliigon.cancel_download"
bl_label = _t("Cancel download")
bl_description = _t("Cancel downloading this asset")
bl_options = {"INTERNAL"}
asset_id: IntProperty(default=0, options={'SKIP_SAVE'}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@reporting.handle_operator(silent=True)
def execute(self, context):
if self.asset_id <= 0:
return {'CANCELLED'}
asset_data = cTB._asset_index.get_asset(self.asset_id)
if asset_data is None:
return {'CANCELLED'}
asset_data.state.dl.cancel()
cTB.logger.debug(f"Cancelled download {self.asset_id}")
self.report({'WARNING'}, _t("Cancelling download"))
return {'FINISHED'}
@@ -0,0 +1,95 @@
# #### 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 typing import List
import bpy
from bpy.types import Operator
from bpy.props import StringProperty
from ..modules.poliigon_core.api_remote_control_params import KEY_TAB_ONLINE
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_category(Operator):
bl_idname = "poliigon.poliigon_category"
bl_label = _t("Select a Category")
bl_description = _t("Select a Category")
bl_options = {"REGISTER", "INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
data: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@staticmethod
def _show_categories_menu(cTB, categories: List[str], index: str) -> None:
"""Generates the popup menu to display category selection options."""
@reporting.handle_draw()
def draw(self, context):
layout = self.layout
row = layout.row()
col = row.column(align=True)
for idx_category in range(len(categories)):
if idx_category > 0 and idx_category % 15 == 0:
col = row.column(align=True)
button = categories[idx_category]
op = col.operator("poliigon.poliigon_setting", text=button)
op.mode = f"category_{index}_{button}"
op.tooltip = _t("Select {0}").format(button)
if idx_category == 0:
col.separator()
area = cTB.settings["area"]
if area == KEY_TAB_ONLINE and index == "0":
col.separator()
tooltip = _t("Search for Free Assets")
op = col.operator("poliigon.poliigon_setting", text=_t("Free"))
op.mode = "search_free"
op.tooltip = tooltip
bpy.context.window_manager.popup_menu(draw)
def execute(self, context):
idx = self.data.split("@")[0]
categories = self.data.split("@")[1:]
self._show_categories_menu(
cTB,
categories=categories,
index=idx
)
return {"FINISHED"}
@@ -0,0 +1,49 @@
# #### 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 bpy.types import Operator
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_check_update(Operator):
bl_idname = "poliigon.check_update"
bl_label = _t("Check for update")
bl_description = _t("Check for any addon updates")
bl_options = {"INTERNAL"}
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@reporting.handle_operator(silent=True)
def execute(self, context):
cTB.logger.debug("Started check for update with "
f"{cTB._updater.addon_version} "
f"{cTB._updater.software_version}")
cTB._updater.async_check_for_update(
callback=cTB.check_update_callback, create_notifications=True)
cTB.logger.debug("Update ready? "
f"{cTB._updater.update_ready}"
f"{cTB._updater.update_data}")
return {'FINISHED'}
@@ -0,0 +1,56 @@
# #### 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 bpy.types import Operator
from bpy.props import IntProperty
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_close_notification(Operator):
bl_idname = "poliigon.close_notification"
bl_label = ""
bl_description = _t("Close notification")
bl_options = {"INTERNAL"}
notification_index: IntProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return _t("Close notification") # Avoids having an extra blank line.
@reporting.handle_operator()
def execute(self, context):
notice = cTB.notify.get_top_notice(do_signal_view=False)
if notice is None:
self.report(
{'ERROR'},
_t("Could not dismiss notification, out of bounds.")
)
return {'CANCELLED'}
cTB.notify.dismiss_notice(notice)
return {'FINISHED'}
@@ -0,0 +1,60 @@
# #### 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 bpy.types import Operator
from bpy.props import StringProperty
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
# TODO(Andreas): currently not used at all?
class POLIIGON_OT_detail(Operator):
bl_idname = "poliigon.poliigon_detail"
bl_label = ""
bl_description = _t("Reset Property to Default")
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"REGISTER", "INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
data: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@classmethod
def poll(cls, context):
return context.active_object is not None
@reporting.handle_operator()
def execute(self, context):
if context.object.cycles.dicing_rate != context.scene.vDispDetail:
context.object.cycles.dicing_rate = context.scene.vDispDetail
context.object.modifiers["Subdivision"].subdivision_type = "SIMPLE"
return {"FINISHED"}
@@ -0,0 +1,851 @@
# #### 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 enum import IntEnum
from functools import partial
import os
from time import monotonic
from typing import Dict, Optional
import bpy
from bpy.props import (
IntProperty,
StringProperty)
from bpy.types import Operator
import bpy.utils.previews
from ..modules.poliigon_core.api_remote_control import ApiJob
from ..modules.poliigon_core.assets import (
AssetData,
AssetType)
from ..modules.poliigon_core.multilingual import _t
from ..dialogs.utils_dlg import check_convention
from ..dialogs.dlg_assets import (
_asset_is_local,
_determine_in_scene_sizes,
_determine_thumb_width,
_draw_button_download,
_draw_button_hdri_local,
_draw_button_model_local,
_draw_button_purchase,
_draw_button_quick_menu,
_draw_button_texture_local,
_draw_button_unsupported_convention,
_draw_thumb_state_asset_downloading,
_draw_thumb_state_asset_purchasing,
_draw_thumb_state_cancelling_download,
THUMB_SIZE_FACTOR)
from ..toolbox import get_context
from ..utils import load_image
from .. import reporting
class MODE_SELECT(IntEnum):
# Negative values, as zero and positive ones represent an index
NEXT = -1
PREVIOUS = -2
class DetailViewState():
"""Global state class used to transfer information between the three
involved operators.
"""
def __init__(self, num_previews: int):
self.num_previews: int = num_previews
self.idx_preview: int = 0
self.img_downloading: Optional[bpy.types.Image] = None
self.img_error: Optional[bpy.types.Image] = None
self.imgs_preview: Dict[int, bpy.types.Image] = {}
self.region_popup: Optional[bpy.types.Region] = None
self.open_retries: int = 3
self.popup_closed: bool = False # new state, popup about to open
self.load_dummy_images()
self.init_preview_image_dict(num_previews)
def load_dummy_images(self) -> None:
"""Loads the dummy images for 'downloading' and 'download error'."""
theme = bpy.context.preferences.themes[0]
color_bg = theme.user_interface.wcol_menu_back.inner
if ".POLIIGON_PREVIEW_downloading" not in bpy.data.images:
path = os.path.join(cTB.dir_script, "get_preview_600px.png")
self.img_downloading = load_image(
"POLIIGON_PREVIEW_downloading",
path,
do_remove_alpha=False,
color_bg=color_bg)
# We do not want this image saved into the blend file
self.img_downloading.user_clear()
if ".POLIIGON_PREVIEW_error" not in bpy.data.images:
path = os.path.join(cTB.dir_script, "icon_nopreview_600px.png")
self.img_error = load_image(
"POLIIGON_PREVIEW_error",
path,
do_remove_alpha=False,
color_bg=color_bg)
# We do not want this image saved into the blend file
self.img_error.user_clear()
def init_preview_image_dict(self, num_previews: int) -> None:
"""Prepares the image dictionary with 'downloading dummies'."""
self.imgs_preview = {}
for _idx_preview in range(num_previews):
self.imgs_preview[_idx_preview] = self.img_downloading
def set_image(self, idx_preview: int, img: bpy.types.Image) -> None:
"""Stores the given image in previews dictionary."""
self.imgs_preview[idx_preview] = img
def set_error_image(self, idx_preview: int) -> None:
"""Sets given preview index to error state (storing error image in
preview dictionary).
"""
self.imgs_preview[idx_preview] = self.img_error
def cleanup_images(self) -> None:
"""Removes all images from blend data."""
for _img in self.imgs_preview.values():
if _img.name in [".POLIIGON_PREVIEW_downloading",
".POLIIGON_PREVIEW_error"]:
continue
_img.user_clear()
bpy.data.images.remove(_img)
self.imgs_preview = {}
if self.img_error is not None and ".POLIIGON_PREVIEW_error" in bpy.data.images:
self.img_error.user_clear()
bpy.data.images.remove(self.img_error)
if self.img_downloading is not None and ".POLIIGON_PREVIEW_downloading" in bpy.data.images:
self.img_downloading.user_clear()
bpy.data.images.remove(self.img_downloading)
def next_index(self) -> None:
self.idx_preview = (self.idx_preview + 1) % self.num_previews
def previous_index(self) -> None:
self.idx_preview = (self.idx_preview - 1) % self.num_previews
def set_index(self, idx_preview: int) -> None:
self.idx_preview = idx_preview
# Global state, only valid between opening and closing the popup.
g_state: Optional[DetailViewState] = None
g_open_request_s: Optional[float] = None # stores a monotonic timestamp
class POLIIGON_OT_detail_view_select(Operator):
bl_idname = "poliigon.detail_view_select"
bl_label = ""
bl_description = _t("Select another large thumb in detail view")
bl_options = {"INTERNAL"}
select_preview: IntProperty(min=MODE_SELECT.PREVIOUS, options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
select_preview = properties.select_preview
if select_preview == MODE_SELECT.NEXT:
return _t("Next Preview")
elif select_preview == MODE_SELECT.PREVIOUS:
return _t("Previous Preview")
elif 0 <= select_preview < MODE_SELECT.NEXT:
return _t("Select Preview #{0}").format(select_preview)
else:
# Not reported, consequences are rather minimal
cTB.logger.error(
"Operator detail_view_select, tooltip unknown mode! "
f"{select_preview}")
return "" # Should not happen
@reporting.handle_operator()
def execute(self, context):
global g_state
if self.select_preview == MODE_SELECT.NEXT:
g_state.next_index()
elif self.select_preview == MODE_SELECT.PREVIOUS:
g_state.previous_index()
elif 0 <= self.select_preview < 10000:
g_state.set_index(self.select_preview)
else:
# Not reported, consequences are rather minimal
cTB.logger.error(
"Operator detail_view_select, execute unknown mode! "
f"{self.select_preview}")
return {'FINISHED'}
def _start_timer_load_and_redraw(
idx_preview: int,
path_preview: str,
do_load_image: bool = True
) -> None:
"""Starts a one-shot timer, which will (optionally) load a preview
image and afterwards tag the popup for redraw.
Reason is, API RC's done callback (see _callback_thumb_done()) is
running in threaded context and we can not reliably load an image into
a Blender data block, if not on main thread.
"""
global g_state
partial_load_and_redraw = partial(
t_load_and_redraw_preview,
region_popup=g_state.region_popup,
dict_imgs=g_state.imgs_preview,
idx_preview=idx_preview,
path_preview=path_preview,
img_error=g_state.img_error,
do_load_image=do_load_image
)
bpy.app.timers.register(
partial_load_and_redraw, first_interval=0, persistent=False)
def load_thumb_image(
idx_preview: int,
path_preview: str,
img_error: bpy.types.Image
) -> bpy.types.Image:
"""Loads in a preview image, returning the error dummy on failure."""
theme = bpy.context.preferences.themes[0]
color_bg = theme.user_interface.wcol_menu_back.inner
img_preview = load_image(
f"POLIIGON_PREVIEW_{idx_preview}",
path_preview,
# With some (NOT all) preview images, setting colorspace fails
do_set_colorspace=False,
# Index 0 preview (identical to the thumbnail image) comes with an
# alpha channel, confusing our template_icon widget. So, we'll replace
# the transparent parts with a new background color.
do_remove_alpha=idx_preview == 0,
color_bg=color_bg,
force=True)
if img_preview is None:
img_preview = img_error
return img_preview
class POLIIGON_OT_detail_view(Operator):
bl_idname = "poliigon.detail_view"
bl_label = _t("Asset Details")
bl_description = _t("View larger thumbnails and asset details")
bl_options = {"INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
def __del__(self):
global g_state
# Docs say, there would be super().__del__(), but seems, there is not.
# super().__del__()
if bpy.app.timers.is_registered(t_periodic_redraw):
bpy.app.timers.unregister(t_periodic_redraw)
if g_state is None:
return
g_state.cleanup_images()
g_state.popup_closed = True
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return cls.bl_description
@reporting.handle_invoke()
def invoke(self, context, event):
global g_open_request_s
g_open_request_s = None
self.asset_data = cTB._asset_index.get_asset(self.asset_id)
if self.asset_data is None:
msg = (f"Asset ID {self.asset_id} not found in AssetIndex upon "
"opening Detail View")
cTB.logger.error(msg)
reporting.capture_message("detail-view-no-asset-2", msg, "error")
return {'CANCELLED'}
cTB.track_screen("large_preview")
return context.window_manager.invoke_props_dialog(
self, width=450)
def _add_preview_image(
self, layout: bpy.types.UILayout, idx_selected: int) -> None:
"""Adds the actual preview image to the popup dialog."""
global g_state
row_image = layout.row()
col_image = row_image.column()
col_image.scale_y = 1.125
col_image.template_icon(
icon_value=g_state.imgs_preview[idx_selected].preview.icon_id,
scale=18.0)
def _add_select_previous_button(self, layout: bpy.types.UILayout) -> None:
"""Adds the button to select the previous preview image to the popup
dialog.
"""
col_btn_prev = layout.column()
col_btn_prev.alignment = "LEFT"
op = col_btn_prev.operator(
"poliigon.detail_view_select",
text="",
icon="TRIA_LEFT",
emboss=False)
op.select_preview = int(MODE_SELECT.PREVIOUS)
def _add_select_next_button(self, layout: bpy.types.UILayout) -> None:
"""Adds the button to select the next preview image to the popup
dialog.
"""
col_btn_next = layout.column()
col_btn_next.alignment = "RIGHT"
op = col_btn_next.operator(
"poliigon.detail_view_select",
text="",
icon="TRIA_RIGHT",
emboss=False)
op.select_preview = int(MODE_SELECT.NEXT)
def _add_select_index_buttons(
self, layout: bpy.types.UILayout, idx_selected: int) -> None:
"""Adds buttons to select a specific preview image to the popup
dialog.
"""
global g_state
for _idx in range(g_state.num_previews):
if _idx == idx_selected:
icon = "RADIOBUT_ON"
else:
icon = "RADIOBUT_OFF"
op = layout.operator(
"poliigon.detail_view_select",
text="",
icon=icon,
emboss=False,
depress=_idx == idx_selected)
op.select_preview = _idx
def _add_selection_buttons(
self, layout: bpy.types.UILayout, idx_selected: int) -> None:
"""Adds a row with various buttons to select the preview image to
display.
"""
row_select = layout.row(align=True)
self._add_select_previous_button(row_select)
row_select.separator()
row_select.label(text="") # Needed for dot buttons centered
self._add_select_index_buttons(row_select, idx_selected)
row_select.label(text="") # Needed for dot buttons centered
row_select.separator()
self._add_select_next_button(row_select)
def _add_meta_data_asset_name(self, layout: bpy.types.UILayout) -> None:
"""Adds labels with the asset name to the popup dialog."""
col_asset_name = layout.column()
col_asset_name.scale_y = 0.8
col_asset_name.label(text=self.asset_data.display_name)
col_asset_name.label(text=self.asset_data.asset_name)
def _add_action_buttons(self, layout: bpy.types.UILayout) -> None:
"""Adds row with 'action buttons' to the popup dialog."""
# TODO(Andreas): No need to comment on this function. It currently is
# a replica of what dlg_assets is doing.
# Possible backlog task: Would like to restructure the
# button implementation in dlg_assets (maybe introducing
# a few helper functions in addon-core) so it can be
# easily re-used here.
asset_data = self.asset_data
api_convention = asset_data.get_convention()
asset_id = asset_data.asset_id
asset_type = asset_data.asset_type
asset_type_data = asset_data.get_type_data()
is_tex = asset_type == AssetType.TEXTURE
is_purchased = asset_data.is_purchased
is_local = asset_data.is_local
is_downloaded = _asset_is_local(cTB, asset_data)
is_unlimited = cTB.is_unlimited_user()
is_selection = len(bpy.context.selected_objects) > 0
is_purchase_in_progress = asset_data.state.purchase.is_in_progress()
is_cancelled = asset_data.state.dl.is_cancelled()
is_download_in_progress = asset_data.state.dl.is_in_progress()
thumb_size_factor = THUMB_SIZE_FACTOR[cTB.settings["thumbsize"]]
thumb_width = _determine_thumb_width(cTB, thumb_size_factor)
size_pref = cTB.get_pref_size(asset_type)
size_default = asset_type_data.get_size(
size_pref,
local_only=is_downloaded,
addon_convention=cTB._asset_index.addon_convention,
local_convention=self.asset_data.local_convention)
sizes_in_scene, size_default = _determine_in_scene_sizes(
cTB, asset_data, size_default)
size_default = asset_data.get_current_size(
size_default,
local_only=is_local,
addon_convention=cTB.addon_convention)
if is_tex and api_convention >= 1:
map_prefs = cTB.user.map_preferences
is_downloaded = asset_type_data.all_expected_maps_local(
map_prefs, size=size_default)
is_in_progress = is_purchase_in_progress or is_cancelled or is_download_in_progress
have_folder_button = not is_in_progress and is_downloaded
row_action_buttons = layout.row()
if have_folder_button:
split_action_buttons = row_action_buttons.split(factor=0.3333)
row_main_action = split_action_buttons.row(align=True)
split_other_buttons = split_action_buttons.split(factor=0.5)
row_folder_button = split_other_buttons.row()
row_link_button = split_other_buttons.row()
else:
split_action_buttons = row_action_buttons.split(factor=0.5)
row_main_action = split_action_buttons.row(align=True)
row_link_button = split_action_buttons.row()
if is_purchase_in_progress:
_draw_thumb_state_asset_purchasing(row_main_action, asset_data)
self._start_timer_periodic_redraw(asset_data)
elif is_cancelled:
_draw_thumb_state_cancelling_download(row_main_action, asset_data)
self._start_timer_periodic_redraw(asset_data)
elif is_download_in_progress:
_draw_thumb_state_asset_downloading(
row_main_action, asset_data, thumb_width)
self._start_timer_periodic_redraw(asset_data)
elif is_purchased or is_unlimited:
if is_downloaded:
if asset_type == AssetType.MODEL:
_draw_button_model_local(
cTB, row_main_action, asset_data, error=None)
elif asset_type == AssetType.TEXTURE:
_draw_button_texture_local(
cTB,
row_main_action,
asset_data,
error=None,
sizes_in_scene=sizes_in_scene,
size_default=size_default,
is_selection=is_selection)
elif asset_type == AssetType.HDRI:
_draw_button_hdri_local(
cTB,
row_main_action,
asset_data,
error=None,
size_default=size_default)
else:
if not check_convention(asset_data):
_draw_button_unsupported_convention(row_main_action)
else:
_draw_button_download(
cTB,
row_main_action, asset_data,
error=None,
size_default=size_default)
else:
if not check_convention(asset_data):
_draw_button_unsupported_convention(row_main_action)
else:
_draw_button_purchase(
cTB,
row_main_action,
asset_data,
error=None,
size_default=size_default)
have_quickmenu = is_downloaded or check_convention(asset_data, is_local)
if have_quickmenu and not is_in_progress:
_draw_button_quick_menu(
row_main_action, asset_data, hide_detail_view=True)
if have_folder_button:
op = row_folder_button.operator(
"poliigon.poliigon_folder",
text=_t("Open folder location"),
icon="FILE_FOLDER"
)
op.asset_id = asset_id
op = row_link_button.operator(
"poliigon.poliigon_link",
text=_t("View online"),
icon_value=cTB.ui_icons["ICON_poliigon"].icon_id,
)
op.mode = str(asset_id)
op.tooltip = _t("View on Poliigon.com")
def _add_meta_data_details_map_types(
self,
col_key: bpy.types.UILayout,
col_value: bpy.types.UILayout,
key: str,
value: str
) -> None:
"""Map type details need to be wrapped into multiple lines."""
MAX_CHAR_PER_LINE = 40
map_type_names = value.split(", ")
num_char_on_line = 0
lines = []
line_current = []
for _map_type_name in map_type_names:
num_char_type = len(_map_type_name)
if num_char_on_line + num_char_type < MAX_CHAR_PER_LINE:
line_current.append(_map_type_name)
num_char_on_line += num_char_type
else:
line = ", ".join(line_current)
lines.append(line)
num_char_on_line = num_char_type
line_current = [_map_type_name]
if len(line_current) > 0:
line = ", ".join(line_current)
lines.append(line)
for _line in lines:
col_key.label(text=key)
col_value.label(text=_line)
key = ""
def _add_meta_data_details(self, layout: bpy.types.UILayout) -> None:
"""Adds a 'table' with asset's details to the popup dialog."""
row_infos = layout.split(factor=0.3)
col_key = row_infos.column()
col_key.scale_y = 0.8
col_value = row_infos.column()
col_value.scale_y = 0.8
details = self.asset_data.get_display_details_data()
for _key, _value in details.items():
if _key == "Maps":
self._add_meta_data_details_map_types(
col_key, col_value, _key, _value)
else:
col_key.label(text=_key)
col_value.label(text=str(_value))
def _add_meta_data_section(self, layout: bpy.types.UILayout) -> None:
"""Adds the meta data section to the popup dialog."""
col_meta_data = layout.column()
self._add_meta_data_asset_name(col_meta_data)
col_meta_data.separator()
self._add_action_buttons(col_meta_data)
col_meta_data.separator()
self._add_meta_data_details(col_meta_data)
@reporting.handle_draw()
def draw(self, context):
global g_state
g_state.region_popup = context.region_popup
col_content = self.layout.column()
self._add_preview_image(col_content, g_state.idx_preview)
self._add_selection_buttons(col_content, g_state.idx_preview)
col_content.separator()
self._add_meta_data_section(col_content)
@reporting.handle_operator()
def execute(self, context):
"""Nothing to do, here."""
return {'FINISHED'}
def t_load_and_redraw_preview(
region_popup: Optional[bpy.types.Region],
dict_imgs: Dict[int, bpy.types.Image],
idx_preview: int,
path_preview: str,
img_error: bpy.types.Image,
do_load_image: bool = False
) -> Optional[float]:
"""One-shot timer function to redraw/update the popup dialog.
Optionally loads in a freshly downloaded preview image.
"""
if do_load_image:
img_preview = load_thumb_image(idx_preview, path_preview, img_error)
dict_imgs[idx_preview] = img_preview
if region_popup is not None:
# region_popup.tag_redraw() does not seem to do the trick.
# Not sure, what it is actually supposed to do?
# Luckily tag_refresh_ui() works for our pourposes.
region_popup.tag_refresh_ui()
return None
def t_periodic_redraw(
region_popup: Optional[bpy.types.Region],
asset_data: AssetData
) -> Optional[float]:
"""Timer function to redraw/update the popup dialog.
Based on asset's state, the timer will either be scheduled to fire again or
auto-disarm itself, if asset's state shows no more ongoing actions like for
example 'downloading'.
"""
next_update_s = None # Auto-disarm, if none of the states below
is_purchasing = asset_data.state.purchase.is_in_progress()
is_downloading = asset_data.state.dl.is_in_progress()
is_cancelling = asset_data.state.dl.is_cancelled()
if is_purchasing or is_downloading or is_cancelling:
next_update_s = 0.250
if region_popup is not None:
region_popup.tag_refresh_ui()
return next_update_s
def t_open_detail_view(asset_id: int) -> Optional[float]:
"""Opens our asset detail view popup.
Called on by blender timer handlers to allow execution on main thread.
The returned value signifies how long until the next execution.
"""
global g_state
if bpy.context.window_manager.is_interface_locked:
if g_state.open_retries > 0:
msg = ("UI locked upon opening Detail View, will retry "
f"{g_state.open_retries} times.")
cTB.logger.warning(msg)
g_state.open_retries -= 1
return 0.05
else:
msg = "Gave up opening Detail View after three retries."
cTB.logger.critical(msg)
reporting.capture_message("dateil-view-ui-locked", msg, "error")
return None
bpy.ops.poliigon.detail_view("INVOKE_DEFAULT", asset_id=asset_id)
return None # Auto disarm, one-shot timer
class POLIIGON_OT_detail_view_open(Operator):
"""Helper operator to allow opening the detail view popup from quickmenu
(by calling the actual detail view operator from a one-shot timer on main
thread).
"""
bl_idname = "poliigon.detail_view_open"
bl_label = _t("Open Asset Details")
bl_description = _t("Opens the detail view with larger thumbnails and "
"asset details")
bl_options = {"INTERNAL"}
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
resolution_dl: IntProperty(min=300, default=600, options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
def _start_timer_periodic_redraw(self, asset_data: AssetData) -> None:
"""Starts a timer to periodically upate/redraw the popup.
For longer running actions like asset download, we want regular popup
updates, so it can display progress bars and switch to different
buttons after the action has finished. Therefore we start a timer,
which will do the trick and which will auto-disarm itself, once the
asset's state signals no more ongoing actions in background.
"""
global g_state
if bpy.app.timers.is_registered(t_periodic_redraw):
return
partial_periodic_redraw = partial(
t_periodic_redraw,
region_popup=g_state.region_popup,
asset_data=asset_data)
bpy.app.timers.register(
partial_periodic_redraw,
first_interval=0,
persistent=False)
def _callback_thumb_done(self, job: ApiJob) -> None:
"""DCC specific finalization of 'download thumb' job."""
global g_state
if g_state.popup_closed:
return
idx_preview = job.params.idx_thumb
path_preview = job.params.path
if not os.path.isfile(path_preview):
g_state.set_error_image(idx_preview)
do_load_image = False
else:
do_load_image = True
_start_timer_load_and_redraw(
idx_preview, path_preview, do_load_image)
def _get_thumb(self, idx_preview: Optional[int] = None) -> None:
"""Either loads a large preview (if file exists) or
starts a job to download it.
"""
global g_state
if idx_preview is None:
idx_preview = g_state.idx_preview
path_preview, url_preview = cTB._asset_index.get_cf_thumbnail_info(
self.asset_id, self.resolution_dl, idx_preview)
if path_preview is None:
msg = (
f"Asset ID {self.asset_id}: "
f"Encountered preview index {idx_preview} returning no path")
cTB.logger.warning(msg)
reporting.capture_message("detail-view-thumb-index", msg, "error")
g_state.set_error_image(idx_preview)
return
if os.path.isfile(path_preview):
img_preview = load_thumb_image(
idx_preview, path_preview, g_state.img_error)
img_preview.user_clear()
g_state.set_image(idx_preview, img_preview)
else:
cTB.api_rc.add_job_download_thumb(
asset_id=self.asset_id,
url=url_preview,
path=path_preview,
idx_thumb=idx_preview,
callback_done=self._callback_thumb_done,
)
def _get_all_thumbs(self, num_previews: int) -> None:
"""Requests all large previews for the asset."""
for _idx_preview in range(num_previews):
self._get_thumb(_idx_preview)
@classmethod
def description(cls, context, properties):
return cls.bl_description
@reporting.handle_operator()
def execute(self, context):
"""Registers a one shot timer, which then in turn calls the detail view
operator.
"""
global g_state
global g_open_request_s
asset_data = cTB._asset_index.get_asset(self.asset_id)
if asset_data is None:
msg = (f"Asset ID {self.asset_id} not found in AssetIndex before "
"opening Detail View")
cTB.logger.error(msg)
reporting.capture_message("detail-view-no-asset-1", msg, "error")
return {'CANCELLED'}
g_open_request_s = monotonic()
num_previews = len(asset_data.cloudflare_thumb_urls)
g_state = DetailViewState(num_previews)
partial_open_detail_view = partial(
t_open_detail_view, asset_id=self.asset_id)
bpy.app.timers.register(
partial_open_detail_view, first_interval=0, persistent=False)
self._get_all_thumbs(g_state.num_previews)
return {'FINISHED'}
def check_and_report_detail_view_not_opening() -> None:
global g_open_request_s
if g_open_request_s is None:
return
diff = monotonic() - g_open_request_s
if diff < 1.0:
return
msg = (f"Detail Viewer did not open (since {diff:.03} s)")
cTB.logger.error(msg)
reporting.capture_message("detail-view-not-opened", msg, "error")
g_open_request_s = None
@@ -0,0 +1,82 @@
# #### 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
from bpy.types import Operator
from bpy.props import StringProperty
import bpy.utils.previews
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
# TODO(Andreas): Pull all lib dir handling into this, instead of having it
# spread here and in two "modes" of operator_setting and operator_library!!!
class POLIIGON_OT_directory(Operator):
bl_idname = "poliigon.poliigon_directory"
bl_label = _t("Add Additional Directory")
bl_description = _t("Add Additional Directory to search for assets")
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"REGISTER", "INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
directory: StringProperty(subtype="DIR_PATH") # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@reporting.handle_operator()
def execute(self, context):
global cTB
directory = self.directory.replace("\\", "/")
cTB.logger.debug(f"POLIIGON_OT_directory execute: {directory}")
if not os.path.exists(directory):
return {"FINISHED"}
if directory in cTB.settings["disabled_dirs"]:
cTB.settings["disabled_dirs"].remove(directory)
cTB.add_library_path(
directory, primary=False, update_local_assets=True)
cTB.refresh_ui()
# TODO(Andreas): What is this?
# poliigon_setting has no invoke function...
bpy.ops.poliigon.poliigon_setting("INVOKE_DEFAULT")
return {"FINISHED"}
def invoke(self, context, event):
cTB.logger.debug(f"POLIIGON_OT_directory invoke: {self.directory}")
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
@@ -0,0 +1,253 @@
# #### 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 threading import Event
import bpy
from bpy.props import (
BoolProperty,
IntProperty,
StringProperty)
from bpy.types import Operator
from ..modules.poliigon_core.api_remote_control import ApiJob
from ..modules.poliigon_core.assets import AssetData
from ..modules.poliigon_core.multilingual import _t
from ..dialogs.utils_dlg import get_ui_scale, wrapped_label
from ..constants import POPUP_WIDTH_NARROW, POPUP_WIDTH_LABEL_NARROW
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_download(Operator):
bl_idname = "poliigon.poliigon_download"
bl_label = ""
bl_description = _t("(Download Asset from Poliigon.com)")
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"REGISTER", "INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
mode: StringProperty(options={"HIDDEN"}) # noqa: F821
size: StringProperty(options={"HIDDEN"}) # noqa: F821
do_synchronous: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
def _callback_done_sync(self, job: ApiJob) -> None:
cTB.callback_asset_update_ui(job)
self.event_sync.set()
def create_auto_download_job(self, asset_data: AssetData) -> ApiJob:
"""Create the follow up download job to attach to purchase job."""
# NOTE: Free assets are implicitly always "auto-download"
credits = 0 if asset_data.credits is None else asset_data.credits
if not cTB.settings["auto_download"] and credits > 0:
return None
name_renderer = "Cycles"
fbx_only = False
if fbx_only:
native_mesh = False
else:
native_mesh = bool(cTB.settings["download_prefer_blend"])
if native_mesh:
download_lods = False
else:
download_lods = bool(cTB.settings["download_lods"])
if self.do_synchronous:
self.event_sync = Event()
callback_done = self._callback_done_sync
else:
callback_done = cTB.callback_asset_update_ui
job_download = cTB.api_rc.create_job_download_asset(
asset_data,
size=self.size,
size_bg=self.size,
type_bg=None,
lod="NONE",
variant="",
download_lods=download_lods,
native_mesh=native_mesh,
renderer=name_renderer,
callback_progress=cTB.callback_asset_update_ui,
callback_done=callback_done
)
return job_download
@reporting.handle_operator()
def execute(self, context):
asset_data = cTB._asset_index.get_asset(self.asset_id)
if self.mode == "download":
if ";" in self.size:
# Presumed previously supported multi size downloads using ;
# as a separator. No longer allowed.
reporting.capture_message(
"reached_legacy_multi_vsize_for_dl",
f"size contained unexpected `;` {self.size}")
self.report(
{"ERROR"},
_t("Failed to download, multiple sizes specified")
)
return {"CANCELLED"}
size = None
if self.size != "":
size = self.size
cTB.logger.debug("POLIIGON_OT_download Queue download asset "
f"{self.asset_id}")
name_renderer = "Cycles"
fbx_only = False
if fbx_only:
native_mesh = False
download_lods = cTB.settings["download_lods"]
else:
native_mesh = bool(cTB.settings["download_prefer_blend"])
if native_mesh:
download_lods = False
else:
download_lods = bool(cTB.settings["download_lods"])
if self.do_synchronous:
self.event_sync = Event()
callback_done = self._callback_done_sync
else:
callback_done = cTB.callback_asset_update_ui
cTB.api_rc.add_job_download_asset(
asset_data,
size=size,
size_bg="",
type_bg="EXR",
lod="NONE",
variant=None,
download_lods=download_lods,
native_mesh=native_mesh,
renderer=name_renderer,
callback_progress=cTB.callback_asset_update_ui,
callback_done=callback_done
)
elif self.mode == "purchase":
cTB.logger.debug("POLIIGON_OT_download Purchase asset "
f"{self.asset_id}")
job_download = self.create_auto_download_job(asset_data)
area = cTB.settings["area"]
search = cTB.vSearch[area]
one_click_purchase = cTB.settings["one_click_purchase"]
user_unlimited = cTB.is_unlimited_user()
credits = 0 if asset_data.credits is None else asset_data.credits
if credits > 0 and one_click_purchase and not user_unlimited:
bpy.ops.poliigon.popup_first_download("INVOKE_DEFAULT")
if self.do_synchronous and job_download is None:
self.event_sync = Event()
callback_done = self._callback_done_sync
else:
callback_done = cTB.callback_asset_update_ui
cTB.api_rc.add_job_purchase_asset(
asset_data,
cTB.settings["category"][area],
search,
job_download=job_download,
callback_done=callback_done,
force=True
)
if self.do_synchronous:
self.event_sync.wait(30.0)
cTB.refresh_ui()
return {"FINISHED"}
class POLIIGON_OT_popup_purchase(Operator):
bl_idname = "poliigon.popup_purchase"
bl_label = _t("Purchase Confirmation")
bl_options = {"INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
mode: StringProperty(options={"HIDDEN"}) # noqa: F821
size: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
def invoke(self, context, event):
cTB.signal_popup(popup="CONFIRM_PURCHASE")
return context.window_manager.invoke_props_dialog(
self, width=POPUP_WIDTH_NARROW)
@reporting.handle_draw()
def draw(self, context):
label_width = POPUP_WIDTH_LABEL_NARROW * get_ui_scale(cTB)
col_content = self.layout.column()
wrapped_label(
cTB,
width=label_width,
text=_t("Would you like to confirm purchase of this asset?"),
container=col_content,
add_padding_bottom=True)
wrapped_label(
cTB,
width=label_width,
text=_t("You can turn this reminder off in preferences by "
"unchecking Show Purchase Confirmation"),
container=col_content)
@reporting.handle_operator(silent=True)
def execute(self, context):
cTB.signal_popup(popup="CONFIRM_PURCHASE", click="CONFIRM_PURCHASE")
bpy.ops.poliigon.poliigon_download(
asset_id=self.asset_id,
mode=self.mode,
size=self.size)
cTB.refresh_ui()
return {'FINISHED'}
@@ -0,0 +1,65 @@
# #### 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 bpy.types import Operator
from bpy.props import IntProperty
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from ..utils import open_dir
from .. import reporting
class POLIIGON_OT_folder(Operator):
bl_idname = "poliigon.poliigon_folder"
bl_label = _t("Open Asset Folder")
bl_description = _t("Open Asset Folder in system browser")
bl_options = {"INTERNAL"}
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@reporting.handle_operator()
def execute(self, context):
asset_data = cTB._asset_index.get_asset(self.asset_id)
asset_name = asset_data.asset_name
path_asset = asset_data.get_asset_directory()
if path_asset is None:
msg = _t("No asset path to open for {0}").format(asset_name)
self.report({"ERROR"}, msg)
reporting.capture_message(
f"open_folder_failed: {msg}")
return {'CANCELLED'}
did_open = open_dir(path_asset)
if not did_open:
reporting.capture_message("open_folder_failed", path_asset)
self.report(
{"ERROR"}, _t("Open folder here: {0}").format(path_asset))
return {'CANCELLED'}
return {"FINISHED"}
@@ -0,0 +1,514 @@
# #### 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 typing import List, Optional, Tuple
import mathutils
import os
import re
from math import pi
import bpy
from bpy.types import Operator
from bpy.props import (
BoolProperty,
EnumProperty,
FloatProperty,
IntProperty,
StringProperty,
)
import bpy.utils.previews
from ..modules.poliigon_core.assets import AssetData
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_hdri(Operator):
bl_idname = "poliigon.poliigon_hdri"
bl_label = _t("HDRI Import")
bl_description = _t("Import HDRI")
bl_options = {"GRAB_CURSOR", "BLOCKING", "REGISTER", "INTERNAL", "UNDO"}
def _fill_light_size_drop_down(
self, context) -> List[Tuple[str, str, str]]:
asset_data = cTB._asset_index.get_asset(self.asset_id)
asset_type_data = asset_data.get_type_data()
# Get list of locally available sizes
asset_files = {}
asset_type_data.get_files(asset_files)
asset_files = list(asset_files.keys())
# Populate dropdown items
local_exr_sizes = []
for path_asset in asset_files:
filename = os.path.basename(path_asset)
if not filename.endswith(".exr"):
continue
match_object = re.search(r"_(\d+K)[_\.]", filename)
if match_object:
local_exr_sizes.append(match_object.group(1))
# Sort by comparing integer size without "K"
local_exr_sizes.sort(key=lambda s: int(s[:-1]))
items_size = []
for size in local_exr_sizes:
# Tuple: (id, name, description, icon, enum value)
items_size.append((size, f"{size} EXR", f"{size} EXR"))
return items_size
def _fill_bg_size_drop_down(self, context) -> List[Tuple[str, str, str]]:
asset_data = cTB._asset_index.get_asset(self.asset_id)
asset_type_data = asset_data.get_type_data()
# Get list of locally available sizes
asset_files = {}
asset_type_data.get_files(asset_files)
asset_files = list(asset_files.keys())
# Populate dropdown items
local_exr_sizes = []
local_jpg_sizes = []
for path_asset in asset_files:
filename = os.path.basename(path_asset)
is_exr = filename.endswith(".exr")
is_jpg = filename.lower().endswith(".jpg")
is_jpg &= "_JPG" in filename
if not is_exr and not is_jpg:
continue
match_object = re.search(r"_(\d+K)[_\.]", filename)
if not match_object:
continue
local_size = match_object.group(1)
if is_exr:
local_exr_sizes.append(f"{local_size}_EXR")
elif is_jpg:
local_jpg_sizes.append(f"{local_size}_JPG")
local_sizes = local_exr_sizes + local_jpg_sizes
# Sort by comparing integer size without "K_JPG" or "K_EXR"
local_sizes.sort(key=lambda s: int(s[:-5]))
items_size = []
for size in local_sizes:
# Tuple: (id, name, description, icon, enum value)
label = size.replace("_", " ")
items_size.append((size, label, label))
return items_size
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
# If do_apply is set True, the sizes are ignored and set internally
do_apply: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
size: EnumProperty(
name=_t("Light Texture"), # noqa F722
items=_fill_light_size_drop_down,
description=_t("Change size of light texture.")) # noqa F722
# This is not a pure size, but is a string like "4K_JPG"
size_bg: EnumProperty(
name=_t("Background Texture"), # noqa F722
items=_fill_bg_size_drop_down,
description=_t("Change size of background texture.")) # noqa F722
hdr_strength: FloatProperty(
name=_t("HDR Strength"), # noqa F722
description=_t("Strength of Light and Background textures"), # noqa F722
soft_min=0.0,
step=10,
default=1.0)
rotation: FloatProperty(
name=_t("Z-Rotation"), # noqa: F821
description=_t("Z-Rotation"), # noqa: F821
unit="ROTATION", # noqa: F821
soft_min=-2.0 * pi,
soft_max=2.0 * pi,
# precision needed here, otherwise Redo Last and node show different values
precision=3,
step=10,
default=0.0)
def __init__(self, *args, **kwargs):
"""Runs once per operator call before drawing occurs."""
super().__init__(*args, **kwargs)
self.exec_count = 0
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@staticmethod
def get_imported_hdri(asset_id: int) -> Optional[bpy.types.Image]:
"""Returns the imported HDRI image (if any)."""
img_hdri = None
for _img in bpy.data.images:
try:
asset_id_img = _img.poliigon_props.asset_id
if asset_id_img != asset_id:
continue
img_hdri = _img
break
except BaseException:
# skip non-Poliigon images (no props)
continue
return img_hdri
@reporting.handle_operator()
def execute(self, context):
asset_data = cTB._asset_index.get_asset(self.asset_id)
asset_type_data = asset_data.get_type_data()
asset_name = asset_data.asset_name
local_convention = asset_data.get_convention(local=True)
addon_convention = cTB.addon_convention
name_light = f"{asset_name}_Light"
name_bg = f"{asset_name}_Background"
try:
if "_" not in self.size_bg:
raise ValueError
size_bg_eff, filetype_bg = self.size_bg.split("_")
except Exception:
msg = ("POLIIGON_OT_hdri: Wrong size_bg format "
f"({self.size_bg}), expected '4K_JPG' or '1K_EXR'")
raise ValueError(msg)
if self.size == size_bg_eff:
name_bg = name_light
cTB.logger.debug("POLIIGON_OT_hdri "
f"{asset_name}, {name_light}, {name_bg}")
existing = self.get_imported_hdri(self.asset_id)
# Whenever an HDR is loaded, it fully replaces the prior loaded
# images/resolutions. Thus, if we are "applying" an already imported
# one, we don't need to worry about resolution selection.
if not self.do_apply or not existing:
# Remove existing images to force loading this resolution.
if name_light in bpy.data.images.keys():
bpy.data.images.remove(bpy.data.images[name_light])
if name_bg in bpy.data.images.keys():
bpy.data.images.remove(bpy.data.images[name_bg])
elif self.do_apply:
if name_light in bpy.data.images.keys():
path_light = bpy.data.images[name_light].filepath
filename = os.path.basename(path_light)
match_object = re.search(r"_(\d+K)[_\.]", filename)
size_light = match_object.group(1) if match_object else cTB.settings["hdri"]
self.size = size_light
if name_bg in bpy.data.images.keys():
path_bg = bpy.data.images[name_bg].filepath
filename = os.path.basename(path_bg)
file_type = "JPG" if "_JPG" in filename else "EXR"
match_object = re.search(r"_(\d+K)[_\.]", filename)
# TODO(Andreas): should next line use cTB.settings["hdribg"] ?
size_bg = match_object.group(1) if match_object else cTB.settings["hdri"]
self.size_bg = f"{size_bg}_{file_type}"
size_bg_eff = size_bg
filetype_bg = file_type
light_exists = name_light in bpy.data.images.keys()
bg_exists = name_bg in bpy.data.images.keys()
if not light_exists or not bg_exists:
if not self.size or self.do_apply:
# Edge case that shouldn't occur as the resolution should be
# explicitly set, or just applying a local tex already,
# but fallback if needed.
self.size = cTB.settings["hdri"]
size_light = asset_type_data.get_size(
self.size,
local_only=True,
addon_convention=addon_convention,
local_convention=local_convention)
files = {}
asset_type_data.get_files(files)
files = list(files.keys())
files_tex_exr = [_file for _file in files
if size_light in os.path.basename(_file) and _file.lower().endswith(".exr")]
if len(files_tex_exr) == 0:
# TODO(Andreas): Shouldn't be needed anymore with AssetIndex
# cTB.f_GetLocalAssets() # Refresh local assets data structure.
msg = (f"Unable to locate image {name_light} with size {size_light}, "
f"try downloading {asset_name} again.")
reporting.capture_message(
"failed_load_light_hdri", msg, "error")
msg = _t(
"Unable to locate image {0} with size {1}, try downloading {2} again."
).format(name_light, size_light, asset_name)
self.report({"ERROR"}, msg)
return {"CANCELLED"}
file_tex_light = files_tex_exr[0]
if cTB.settings["hdri_use_jpg_bg"] and filetype_bg == "JPG":
size_bg = asset_type_data.get_size(
size_bg_eff,
local_only=True,
addon_convention=addon_convention,
local_convention=local_convention)
files_tex_jpg = [_file for _file in files
if size_bg in os.path.basename(_file) and _file.lower().endswith(".jpg")]
if len(files_tex_jpg) == 0:
# TODO(Andreas): Shouldn't be needed anymore with AssetIndex
# cTB.f_GetLocalAssets() # Refresh local assets data structure.
msg = (f"Unable to locate image {name_bg} with size {size_bg} (JPG), "
f"try downloading {asset_name} again.")
reporting.capture_message(
"failed_load_bg_jpg", msg, "error")
msg = _t(
"Unable to locate image {0} with size {1} (JPG), try downloading {2} again."
).format(name_bg, size_bg, asset_name)
self.report({"ERROR"}, msg)
return {"CANCELLED"}
file_tex_bg = files_tex_jpg[0]
elif size_light != size_bg_eff:
size_bg = size_bg_eff
files_tex_exr = [_file for _file in files
if size_bg_eff in os.path.basename(_file) and _file.lower().endswith(".exr")]
if len(files_tex_exr) == 0:
# TODO(Andreas): Shouldn't be needed anymore with AssetIndex
# cTB.f_GetLocalAssets() # Refresh local assets data structure.
msg = (f"Unable to locate image {name_bg} with size {size_bg} (EXR), "
f"try downloading {asset_name} again")
reporting.capture_message(
"failed_load_bg_hdri", msg, "error")
msg = _t(
"Unable to locate image {0} with size {1} (EXR), try downloading {2} again"
).format(name_bg, size_bg, asset_name)
self.report({"ERROR"}, msg)
return {"CANCELLED"}
file_tex_bg = files_tex_exr[0]
else:
size_bg = size_light
file_tex_bg = file_tex_light
# Reset apply for Redo Last menu to work properly
self.do_apply = False
# ...............................................................................................
node_tex_coord = None
node_mapping = None
node_tex_env_light = None
node_background_light = None
node_tex_env_bg = None
node_background_bg = None
node_mix_shader = None
node_light_path = None
node_output_world = None
if not bpy.context.scene.world:
bpy.ops.world.new()
bpy.context.scene.world = bpy.data.worlds[-1]
context.scene.world.use_nodes = True
nodes_world = context.scene.world.node_tree.nodes
links_world = context.scene.world.node_tree.links
for _node in nodes_world:
if _node.type == "TEX_COORD":
if _node.label == "Mapping":
node_tex_coord = _node
elif _node.type == "MAPPING":
if _node.label == "Mapping":
node_mapping = _node
elif _node.type == "TEX_ENVIRONMENT":
if _node.label == "Lighting":
node_tex_env_light = _node
elif _node.label == "Background":
node_tex_env_bg = _node
elif _node.type == "BACKGROUND":
if _node.label == "Lighting":
node_background_light = _node
elif _node.label == "Background":
node_background_bg = _node
elif len(nodes_world) == 2:
node_background_light = _node
node_background_light.label = "Lighting"
node_background_light.location = mathutils.Vector(
(-110, 200))
elif _node.type == "MIX_SHADER":
node_mix_shader = _node
elif _node.type == "LIGHT_PATH":
node_light_path = _node
elif _node.type == "OUTPUT_WORLD":
node_output_world = _node
if node_tex_coord is None:
node_tex_coord = nodes_world.new("ShaderNodeTexCoord")
node_tex_coord.label = "Mapping"
node_tex_coord.location = mathutils.Vector((-1080, 420))
if node_mapping is None:
node_mapping = nodes_world.new("ShaderNodeMapping")
node_mapping.label = "Mapping"
node_mapping.location = mathutils.Vector((-870, 420))
if node_tex_env_light is None:
node_tex_env_light = nodes_world.new("ShaderNodeTexEnvironment")
node_tex_env_light.label = "Lighting"
node_tex_env_light.location = mathutils.Vector((-470, 420))
if node_tex_env_bg is None:
node_tex_env_bg = nodes_world.new("ShaderNodeTexEnvironment")
node_tex_env_bg.label = "Background"
node_tex_env_bg.location = mathutils.Vector((-470, 100))
if node_background_light is None:
node_background_light = nodes_world.new("ShaderNodeBackground")
node_background_light.label = "Lighting"
node_background_light.location = mathutils.Vector((-110, 200))
if node_background_bg is None:
node_background_bg = nodes_world.new("ShaderNodeBackground")
node_background_bg.label = "Background"
node_background_bg.location = mathutils.Vector((-110, 70))
if node_mix_shader is None:
node_mix_shader = nodes_world.new("ShaderNodeMixShader")
node_mix_shader.location = mathutils.Vector((110, 300))
if node_light_path is None:
node_light_path = nodes_world.new("ShaderNodeLightPath")
node_light_path.location = mathutils.Vector((-110, 550))
if node_output_world is None:
node_output_world = nodes_world.new("ShaderNodeOutputWorld")
node_output_world.location = mathutils.Vector((370, 300))
links_world.new(
node_tex_coord.outputs["Generated"],
node_mapping.inputs["Vector"])
links_world.new(
node_mapping.outputs["Vector"],
node_tex_env_light.inputs["Vector"])
links_world.new(
node_tex_env_light.outputs["Color"],
node_background_light.inputs["Color"])
links_world.new(
node_background_light.outputs[0],
node_mix_shader.inputs[1])
links_world.new(
node_tex_coord.outputs["Generated"],
node_mapping.inputs["Vector"])
links_world.new(
node_mapping.outputs["Vector"],
node_tex_env_bg.inputs["Vector"])
links_world.new(
node_tex_env_bg.outputs["Color"],
node_background_bg.inputs["Color"])
links_world.new(
node_background_bg.outputs[0],
node_mix_shader.inputs[2])
links_world.new(
node_light_path.outputs[0],
node_mix_shader.inputs[0])
links_world.new(
node_mix_shader.outputs[0],
node_output_world.inputs[0])
if name_light in bpy.data.images.keys():
img_light = bpy.data.images[name_light]
else:
file_tex_light_norm = os.path.normpath(file_tex_light)
img_light = bpy.data.images.load(file_tex_light_norm)
img_light.name = name_light
img_light.poliigon = "HDRIs;" + asset_name
self.set_poliigon_props_image(img_light, asset_data)
if name_bg in bpy.data.images.keys():
img_bg = bpy.data.images[name_bg]
else:
file_tex_bg_norm = os.path.normpath(file_tex_bg)
img_bg = bpy.data.images.load(file_tex_bg_norm)
img_bg.name = name_bg
self.set_poliigon_props_image(img_bg, asset_data)
if "Rotation" in node_mapping.inputs:
node_mapping.inputs["Rotation"].default_value[2] = self.rotation
else:
node_mapping.rotation[2] = self.rotation
node_tex_env_light.image = img_light
node_background_light.inputs["Strength"].default_value = self.hdr_strength
node_tex_env_bg.image = img_bg
node_background_bg.inputs["Strength"].default_value = self.hdr_strength
self.set_poliigon_props_world(context, asset_data)
cTB.f_GetSceneAssets()
if self.exec_count == 0:
cTB.signal_import_asset(asset_id=self.asset_id)
self.exec_count += 1
self.report({"INFO"}, _t("HDRI Imported : {0}").format(asset_name))
return {"FINISHED"}
def set_poliigon_props_image(
self, img: bpy.types.Image, asset_data: AssetData) -> None:
"""Sets Poliigon property of an imported image."""
img.poliigon_props.asset_name = asset_data.asset_name
img.poliigon_props.asset_id = self.asset_id
img.poliigon_props.asset_type = asset_data.asset_type.name
img.poliigon_props.size = self.size
img.poliigon_props.size_bg = self.size_bg
img.poliigon_props.hdr_strength = self.hdr_strength
img.poliigon_props.rotation = self.rotation
def set_poliigon_props_world(self, context, asset_data: AssetData) -> None:
"""Sets Poliigon property of world."""
context_world = context.scene.world
context_world.poliigon_props.asset_name = asset_data.asset_name
context_world.poliigon_props.asset_id = self.asset_id
context_world.poliigon_props.asset_type = asset_data.asset_type.name
context_world.poliigon_props.size = self.size
context_world.poliigon_props.size_bg = self.size_bg
context_world.poliigon_props.hdr_strength = self.hdr_strength
context_world.poliigon_props.rotation = self.rotation
@@ -0,0 +1,94 @@
# #### 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
from bpy.types import Operator
from bpy.props import (
EnumProperty,
StringProperty)
import bpy.utils.previews
from ..modules.poliigon_core.multilingual import _t
from ..asset_browser.asset_browser import create_poliigon_library
from ..toolbox import get_context
from ..toolbox_settings import save_settings
from .. import reporting
class POLIIGON_OT_library(Operator):
bl_idname = "poliigon.poliigon_library"
bl_label = _t("Poliigon Library")
bl_description = _t("(Set Poliigon Library Location)")
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"REGISTER", "INTERNAL"}
_enum_items = [
("set_library", "set_library", _t("Set path on first load")),
("update_library", "update_library", _t("Update path from preferences"))
]
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
directory: StringProperty(subtype="DIR_PATH") # noqa: F821
mode: EnumProperty(items=_enum_items, options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@reporting.handle_operator()
def execute(self, context):
global cTB
directory = self.directory.replace("\\", "/")
if self.mode == "set_library":
# Stage for confirmation on startup (or after deleted)
cTB.settings["set_library"] = directory
else:
# Update_library, from user preferences
if bpy.app.version >= (3, 0):
create_poliigon_library(force=True)
path_old = cTB.get_library_path(primary=True)
cTB.replace_library_path(
path_old=path_old,
path_new=directory,
primary=True,
update_local_assets=True)
cTB.refresh_ui()
save_settings(cTB)
# if os.path.exists(vDir):
# bpy.ops.poliigon.poliigon_setting("INVOKE_DEFAULT")
return {"FINISHED"}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
@@ -0,0 +1,104 @@
# #### 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 webbrowser
from bpy.types import Operator
from bpy.props import (
IntProperty,
StringProperty)
from ..modules.poliigon_core.multilingual import _t
from ..modules.poliigon_core.notifications import (
NOTICE_ID_SURVEY_FREE,
NOTICE_ID_SURVEY_ACTIVE,
NOTICE_ID_UPDATE)
from ..notifications import get_datetime_now
from ..toolbox import get_context
from ..toolbox_settings import save_settings
from .. import reporting
class POLIIGON_OT_link(Operator):
bl_idname = "poliigon.poliigon_link"
bl_label = ""
bl_description = _t("(Find asset on Poliigon.com in your default browser)")
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"REGISTER", "INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
mode: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@reporting.handle_operator()
def execute(self, context):
global cTB
notice = cTB.notify.get_top_notice(do_signal_view=False)
if self.mode.startswith("notify") and notice is not None:
cTB.notify.clicked_notice(notice)
open_free_survey = notice.id_notice == NOTICE_ID_SURVEY_FREE
open_paying_survey = notice.id_notice == NOTICE_ID_SURVEY_ACTIVE
update_clicked = notice.id_notice == NOTICE_ID_UPDATE
if open_free_survey or open_paying_survey:
time_now = get_datetime_now()
cTB.settings["last_nps_open"] = time_now.timestamp()
save_settings(cTB)
webbrowser.open(notice.url)
elif update_clicked:
# TODO(patrick): Remove reliance on the @struc, and _api itself
if "@Logs" in self.mode:
cTB._api.open_poliigon_link("changelog")
elif "@Update" in self.mode:
webbrowser.open(notice.download_url)
elif hasattr(notice, "url"):
webbrowser.open(notice.url)
else:
reporting.capture_message(
"invalid_notify_url", str(notice), "error")
elif self.mode == "survey" and notice is not None:
cTB._api.open_poliigon_link(self.mode, env_name=cTB._env.env_name)
cTB.notify.clicked_notice(notice)
elif self.mode == "subscribe_banner":
cTB.upgrade_manager.emit_signal(clicked=True)
cTB._api.open_poliigon_link("subscribe", env_name=cTB._env.env_name)
elif self.mode in cTB._api._url_paths:
cTB._api.open_poliigon_link(self.mode, env_name=cTB._env.env_name)
elif self.mode.startswith("https:"):
webbrowser.open(self.mode)
else:
# Assume passed in asset id, open asset page.
asset_id = int(self.mode)
cTB.open_asset_url(asset_id)
if notice is not None:
cTB.notify.dismiss_notice(notice)
return {"FINISHED"}
@@ -0,0 +1,152 @@
# #### 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 typing import List, Tuple
import os
import re
from bpy.props import (IntProperty,
StringProperty)
from bpy.types import Operator
from ..modules.poliigon_core.assets import SIZES
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_load_asset_size_from_list(Operator):
# NOTE: This operator is considered internal and therefore not translated
bl_idname = "poliigon.load_asset_size_from_list"
bl_label = "Import list of files as an asset"
bl_description = "Import an asset from a list of files"
bl_options = {'REGISTER', 'INTERNAL'}
# Use negative asset IDs for, now,
# caller to keep track to keep unique within session.
asset_id: IntProperty(default=-1, options={'SKIP_SAVE'}) # noqa: F821
asset_name: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_type: StringProperty(options={"HIDDEN"}) # noqa: F821
file_list_json: StringProperty(options={"HIDDEN"}) # noqa: F821
size: StringProperty(options={"HIDDEN"}) # noqa: F821
lod: StringProperty(options={"HIDDEN"}) # noqa: F821
convention: IntProperty(default=1, options={'SKIP_SAVE'}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
def _validate_properties(self) -> None:
"""Validates received parameters.
Raise ValueError, if validation failed.
"""
if self.asset_id >= 0:
msg = f"Only negative asset IDs allowed, for now (not {self.asset_id})"
self.report({"ERROR"}, msg)
raise ValueError(msg)
if len(self.asset_name) == 0:
msg = "Please specify an asset name"
self.report({"ERROR"}, msg)
raise ValueError(msg)
if self.asset_type not in ["HDRIs", "Models", "Textures"]:
msg = (f"Unknown asset type: {self.asset_type}\n"
"Known types: HDRIs, Models, Textures")
self.report({"ERROR"}, msg)
raise ValueError(msg)
if len(self.size) > 0:
try:
if self.size[-1] == "K":
int(self.size[:-1])
else:
int(self.size)
except ValueError:
msg = (f"Unknown size string: '{self.size}'\n"
"Expected something like: '256', '2K' or '16K'")
raise ValueError(msg)
if len(self.lod) > 0 and not self.lod.startswith("LOD"):
msg = (f"Unknown LOD string format: {self.lod}\n"
"Expected something like: 'LOD0'")
self.report({"ERROR"}, msg)
raise ValueError(msg)
if self.convention < 0:
msg = (f"Invalid asset convention: {self.convention}\n"
"Expected values: convention >= 0")
self.report({"ERROR"}, msg)
raise ValueError(msg)
# TODO(Andreas): Any additional validation needed?
# E.g. test if file types in file_list_json actually
# match file tags?`Like an "xyz.fbx" for COL channel?
def _derive_properties_from_files(
self,
tex_maps: List[str]
) -> Tuple[List[str], List[str], List[str]]:
"""Derives workflows, sizes and LODs from filenames."""
workflows = []
sizes = []
lods = []
for path in tex_maps:
dir_parent = os.path.basename(os.path.dirname(path))
filename = os.path.basename(path)
filename_no_ext, _ = os.path.splitext(filename)
filename_parts = filename_no_ext.split("_")
for part in filename_parts:
match_size = re.search(r"(\d+K)", part)
match_lod = re.search(r"(LOD\d)", part)
if part in ["METALNESS", "SPECULAR", "REGULAR"]:
workflows.append(part)
elif match_size is not None:
sizes.append(part)
elif match_lod is not None:
lods.append(part)
elif dir_parent in SIZES:
sizes.append(dir_parent)
workflows = list(set(workflows))
sizes = list(set(sizes))
lods = list(set(lods))
return workflows, sizes, lods
@reporting.handle_operator()
def execute(self, context):
# Deliberately not catching ValueError, here.
# Scripts using this operator are supposed to fail.
self._validate_properties()
cTB._asset_index.load_asset_from_list(
self.asset_id,
self.asset_name,
self.asset_type,
self.size,
self.lod,
"METALNESS",
self.file_list_json,
convention=self.convention,
# "my_assets",
# -1,
# 1000000
)
return {"FINISHED"}
@@ -0,0 +1,143 @@
# #### 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 time
from threading import Event
from bpy.props import (
BoolProperty,
IntProperty,
FloatProperty)
from bpy.types import Operator
from ..modules.poliigon_core.api_remote_control import ApiJob
from ..modules.poliigon_core.api_remote_control_params import (
CATEGORY_ALL,
KEY_TAB_MY_ASSETS,
KEY_TAB_ONLINE)
from ..constants import ASSET_ID_ALL
from ..toolbox import get_context
class POLIIGON_OT_get_local_asset_sync(Operator):
bl_idname = "poliigon.get_local_asset_sync"
bl_label = "For internal testing, only"
bl_description = "For internal testing, only"
bl_options = {"INTERNAL"}
timeout: FloatProperty(options={"HIDDEN"}, default=60.0) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}, default=ASSET_ID_ALL) # noqa: F821
await_startup_poliigon: BoolProperty(options={"HIDDEN"}, default=True) # noqa: F821
await_startup_my_assets: BoolProperty(options={"HIDDEN"}, default=True) # noqa: F821
get_poliigon: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
get_my_assets: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
abort_ongoing_jobs: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
def _callback_get_asset_done(self, job: ApiJob) -> None:
"""Calls the standard done callback and sets local event afterwards."""
cTB.callback_get_asset_done(job)
last_page = job.params.idx_page >= job.result.body.get("last_page", -1)
if last_page:
self.ev_done.set()
@staticmethod
def _is_startup_done(
*, await_poliigon: bool, await_my_assets: bool) -> bool:
"""Returns True, if the addon is fetching no more asset data."""
startup_done = True
if await_poliigon:
fetching_poliigon = cTB.fetching_asset_data[KEY_TAB_ONLINE]
startup_done = len(fetching_poliigon) == 0
if await_my_assets:
fetching_my_assets = cTB.fetching_asset_data[KEY_TAB_MY_ASSETS]
startup_done &= len(fetching_my_assets) == 0
return startup_done
def _await_end_of_startup(self) -> None:
to_wait_s = int(self.timeout)
startup_done = False
while to_wait_s > 0:
startup_done = self._is_startup_done(
await_poliigon=self.await_startup_poliigon,
await_my_assets=self.await_startup_my_assets)
if startup_done:
break
time.sleep(1)
to_wait_s -= 1
if not startup_done:
print("FAILED TO AWAIT STARTUP END")
def _do_sync_get_assets(self) -> bool:
tabs = []
if self.get_poliigon:
tabs.append(KEY_TAB_ONLINE)
if self.get_my_assets:
tabs.append(KEY_TAB_MY_ASSETS)
search = None
if self.asset_id != ASSET_ID_ALL:
asset_ids = cTB._asset_index.get_asset_id_list()
if self.asset_id in asset_ids:
return True
search = str(self.asset_id)
self.ev_done = Event()
for _tab in tabs:
cTB.f_GetAssets(
area=_tab,
categories=[CATEGORY_ALL],
search=search,
force=True,
callback_done=self._callback_get_asset_done
)
if not self.ev_done.wait(self.timeout):
msg = ("POLIIGON_OT_get_local_asset_sync: Failed to get assets, "
f"tab: {_tab}")
cTB.logger.error(msg)
return False
self.ev_done.clear()
return True
def execute(self, context):
if self.abort_ongoing_jobs:
cTB.api_rc.wait_for_all(do_wait=False)
self._await_end_of_startup()
if not self.get_poliigon and not self.get_my_assets:
return {"FINISHED"}
result = self._do_sync_get_assets()
if not result:
return {"CANCELLED"}
return {"FINISHED"}
@@ -0,0 +1,407 @@
# #### 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 typing import List
from bpy.types import Operator
from bpy.props import (
BoolProperty,
EnumProperty,
FloatProperty,
IntProperty,
StringProperty)
import bpy.utils.previews
from ..modules.poliigon_core.multilingual import _t
from ..material_import_utils import find_identical_material
from .utils_operator import fill_size_drop_down
from ..toolbox import get_context
from ..utils import (
is_cycles,
is_eevee_next)
from .. import reporting
UV_OPTIONS = [("UV", "UV", "UV"),
("MOSAIC", "Mosaic", "Poliigon Mosaic"),
("FLAT", "Flat", "Flat"),
("BOX", "Box", "Box"),
("SPHERE", "Sphere", "Sphere"),
("TUBE", "Tube", "Tube")]
def set_op_mat_disp_strength(ops, asset_name: str, mode_disp: str) -> None:
if asset_name.startswith("Poliigon_"):
ops.displacement = 0.2
elif mode_disp == "MICRO":
ops.displacement = 0.05
else:
ops.displacement = 0.0
class POLIIGON_OT_material(Operator):
bl_idname = "poliigon.poliigon_material"
bl_label = _t("Poliigon Material Import")
bl_description = _t("Create Material")
bl_options = {"GRAB_CURSOR", "BLOCKING", "REGISTER", "INTERNAL", "UNDO"}
def _get_dispopts(self, context):
options = [
("NORMAL", "Normal Only", "Use the Normal Map for surface details")
]
if is_cycles() or is_eevee_next():
# Only cycles and 4.2's eevee next support displacement
options.append(
("BUMP",
"Bump Only",
("Use the displacement map for surface details without "
"displacement"))
)
options.append(
("DISP",
"Displacement and Bump",
("Use the displacement map for surface details and physical "
"displacement"))
)
if is_cycles():
# While Eevee next in blender 4.2 could have the above options,
# below is still only relevnat in cycles.
options.append(
("MICRO",
"Adaptive Displacement Only",
("Use the displacement map for physical displacement with "
"adaptive geometry subdivisions\n"
"Note: This has a high render performance cost!"))
)
return options
def _fill_size_drop_down(self, context):
return fill_size_drop_down(cTB, self.asset_id)
def _update_displacement_options(self, context):
# We can not access self.asset_data, here!
asset_data = cTB._asset_index.get_asset(self.asset_id)
if asset_data.local_convention == 1:
self.displacement = 0.2
else:
self.displacement = 0.05
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
size: EnumProperty(
name=_t("Texture"), # noqa: F821
items=_fill_size_drop_down,
description=_t("Change size of assigned textures.") # noqa: F722
)
mapping: EnumProperty(
name=_t("Mapping"), # noqa: F821
items=UV_OPTIONS,
default="UV" # noqa: F821
)
scale: FloatProperty(
name=_t("Scale"), # noqa: F821
default=1.0
)
if bpy.app.version >= (3, 0):
mode_disp: EnumProperty(name="Displacement", # noqa: F821
items=_get_dispopts,
default=0, # noqa: F821
update=_update_displacement_options)
else:
mode_disp: EnumProperty(name="Displacement", # noqa: F821
items=_get_dispopts,
update=_update_displacement_options)
displacement: FloatProperty(
name=_t("Displacement Strength"), # noqa: F722
default=0.0
)
use_16bit: BoolProperty(
name=_t("16-Bit Textures (if any)"), # noqa: F722
default=False
)
reuse_material: BoolProperty(
name=_t("Reuse Material"), # noqa: F722
default=True
)
keep_unused_tex_nodes: BoolProperty(
name=_t("Keep Additional Texture Nodes"), # noqa: F722
default=True
)
# TODO(Andreas): Can do_rename be removed, seems not in use at all?
do_rename: BoolProperty(options={"HIDDEN"}) # noqa: F821
do_apply: BoolProperty(options={"HIDDEN"}, default=True) # noqa: F821
ignore_map_prefs: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
def __init__(self, *args, **kwargs):
"""Runs once per operator call before drawing occurs."""
super().__init__(*args, **kwargs)
# Note: During property update handlers (like e.g.
# _update_displacement_options()) we can not rely on on these
# members to be defined!
self.asset_data = None
self.exec_count = 0
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
def draw(self, context):
if self.asset_data is None:
self.asset_data = cTB._asset_index.get_asset(self.asset_id)
is_backplate = self.asset_data.is_backplate()
col = self.layout.column()
col.prop(self, "size")
row = col.row()
row.prop(self, "mapping")
row.enabled = not is_backplate
if is_cycles() or is_eevee_next():
row = col.row()
row.prop(self, "mode_disp")
row.enabled = not is_backplate
if self.mode_disp != "NORMAL" and (is_cycles() or is_eevee_next()):
row = col.row()
row.prop(self, "displacement")
row.enabled = not is_backplate
row = col.row()
row.prop(self, "scale") # TODO(Andreas): implement for backplate?
row.enabled = not is_backplate
row = col.row()
row.prop(self, "use_16bit")
row.enabled = not is_backplate
col.prop(self, "reuse_material")
row = col.row()
row.prop(self, "keep_unused_tex_nodes")
row.enabled = not self.reuse_material
def evaluate_displacement_method_property(self) -> None:
"""Tries to set dislacement method from prefs, if not set already."""
if self.properties.is_property_set("mode_disp"):
return
try:
self.mode_disp = cTB.prefs.mode_disp
except TypeError:
# Could be Eevee active while assigning a displacement value
self.mode_disp = "NORMAL"
except AttributeError as e:
reporting.capture_exception(e)
self.mode_disp = "NORMAL"
def determine_objects_needing_subdiv(
self, context) -> List[bpy.types.Object]:
"""Returns a list of selected objects, which still need a Subdivision
modifier for microdisplacement.
"""
if not is_cycles() or self.mode_disp != "MICRO":
return []
objs_selected = [_obj for _obj in context.selected_objects]
objs_add_subdiv = [_obj
for _obj in objs_selected
if "Subdivision" not in _obj.modifiers]
bpy.context.scene.cycles.feature_set = "EXPERIMENTAL"
return objs_add_subdiv
def add_subdiv_to_objects(self, obj_list: List[bpy.types.Object]) -> None:
"""Adds a Subdivision modifier to all objects in list."""
for _obj in obj_list:
_obj.cycles.use_adaptive_subdivision = True
modifier = _obj.modifiers.new("Subdivision", "SUBSURF")
if modifier is None:
# TODO(Andreas): Would we want to report failure to create modifier?
continue
modifier.subdivision_type = "SIMPLE"
modifier.levels = 0 # Don't do subdiv in viewport
def check_material_reuse(self) -> bool:
"""Optionally re-uses an already imported identical material."""
if not self.reuse_material:
return False
identical_mat = find_identical_material(
self.asset_data,
self.size,
self.mapping,
self.scale,
self.displacement,
self.use_16bit,
self.mode_disp
)
if identical_mat is None:
return False
# Prevent duplicate materials from being created unintentionally
# but we probably want to provide an option for that at some point.
cTB.logger.debug("POLIIGON_OT_material Applying existing material: "
f"{identical_mat.name}")
self.report({"WARNING"}, _t("Applying existing material"))
result = bpy.ops.poliigon.poliigon_apply(
"INVOKE_DEFAULT",
asset_id=self.asset_id,
name_material=identical_mat.name
)
if result == {"CANCELLED"}:
self.report(
{"WARNING"}, _t("Could not apply materials to selection"))
else:
self.signal_import()
return True
def signal_import(self) -> None:
if self.exec_count == 0:
cTB.signal_import_asset(asset_id=self.asset_id)
self.exec_count += 1
@reporting.handle_operator()
def execute(self, context):
self.asset_data = cTB._asset_index.get_asset(self.asset_id)
# TODO(Andreas): Can do_rename be removed, seems not in use at all?
if self.do_rename:
mat = bpy.data.materials[cTB.vActiveMat]
cTB.vActiveMat = bpy.context.scene.vEditMatName
mat.name = cTB.vActiveMat
return {"FINISHED"}
asset_type_data = self.asset_data.get_type_data()
asset_name = self.asset_data.asset_name
asset_type = self.asset_data.asset_type
workflow = asset_type_data.get_workflow("METALNESS")
self.size = asset_type_data.get_size(
self.size,
local_only=True,
addon_convention=cTB.addon_convention,
local_convention=self.asset_data.local_convention
)
self.evaluate_displacement_method_property()
objs_add_disp = self.determine_objects_needing_subdiv(context)
# TODO(Andreas): Not sure what this was supposed to be good for:
# msg_error_local = None
# msg_error_my_assets = None
# if msg_error_local is not None:
# self.report({"ERROR"}, msg_error_local)
# return {'CANCELLED'}
# if msg_error_my_assets is not None:
# cTB.print_debug_(
# 0, "apply_mat_size_not_local", asset_name, self.size)
# self.report({"ERROR"}, msg_error_my_assets)
# reporting.capture_message("apply_mat_size_not_local", asset_name)
# return {"CANCELLED"}
cTB.logger.debug(f"POLIIGON_OT_material Size: {self.size}")
did_reuse = self.check_material_reuse()
if did_reuse:
return {"FINISHED"}
tex_maps = asset_type_data.get_maps(
workflow=workflow,
size=self.size,
prefer_16_bit=self.use_16bit,
variant=None)
if len(tex_maps) == 0:
self.report({"WARNING"}, _t("No Textures found."))
reporting.capture_message("apply_mat_tex_not_found", asset_name)
return {"CANCELLED"}
if self.ignore_map_prefs:
map_prefs = None
else:
map_prefs = cTB.user.map_preferences
mat = cTB.mat_import.import_material(
asset_data=self.asset_data,
do_apply=False,
workflow="METALNESS",
size=self.size,
lod="",
projection=self.mapping,
use_16bit=self.use_16bit,
mode_disp=self.mode_disp,
translate_x=0.0,
translate_y=0.0,
scale=self.scale,
global_rotation=0.0,
aspect_ratio=1.0,
displacement=self.displacement,
keep_unused_tex_nodes=self.keep_unused_tex_nodes,
reuse_existing=False, # we already checked for reusable mats before
map_prefs=map_prefs
)
if mat is None:
reporting.capture_message(
"could_not_create_mat", asset_name, "error")
self.report({"ERROR"}, _t("Material could not be created."))
return {"CANCELLED"}
self.add_subdiv_to_objects(objs_add_disp)
cTB.f_GetSceneAssets()
# TODO(Andreas): check what it is used for
# TODO(Andreas): Even more strange, op.poliigon_active does the
# same again...
# TODO(Andreas): Even more more strange, op.poliigon_apply then does
# this again and also calls op.poliigon_active, again?
cTB.vActiveType = asset_type.name # self.vType
cTB.active_asset_id = self.asset_id
cTB.vActiveMat = mat.name
bpy.ops.poliigon.poliigon_active(
mode="mat", asset_type=cTB.vActiveType, data=cTB.vActiveMat
)
self.signal_import()
if not self.do_apply:
return {"FINISHED"}
rtn = bpy.ops.poliigon.poliigon_apply(
"INVOKE_DEFAULT",
asset_id=self.asset_id,
name_material=mat.name
)
if rtn == {"CANCELLED"}:
self.report(
{"WARNING"}, _t("Could not apply materials to selection"))
return {"FINISHED"}
@@ -0,0 +1,894 @@
# #### 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 typing import List, Tuple
import os
import bpy
from bpy.types import Operator
from bpy.props import (
BoolProperty,
EnumProperty,
IntProperty,
StringProperty,
)
from ..modules.poliigon_core.assets import (
AssetData,
LODS,
MAP_EXT_LOWER,
ModelType,
VARIANTS)
from ..modules.poliigon_core.multilingual import _t
from ..material_import_utils import replace_tex_size
from .utils_operator import fill_size_drop_down
from ..toolbox import get_context
from ..utils import (
construct_model_name,
f_Ex,
f_FExt,
f_FName)
from .. import reporting
LOD_DESCS = {
"NONE": _t("med. poly"),
"LOD0": _t("high poly"),
"LOD1": _t("med. poly"),
"LOD2": _t("low poly"),
"LOD3": _t("lower poly"),
"LOD4": _t("min. poly"),
}
LOD_NAME = "{0} ({1})"
LOD_DESCRIPTION_FBX = _t("Import the {0} level of detail (LOD) FBX file")
class POLIIGON_OT_model(Operator):
bl_idname = "poliigon.poliigon_model"
bl_label = _t("Import model")
bl_description = _t("Import Model")
bl_options = {"REGISTER", "INTERNAL", "UNDO"}
def _fill_size_drop_down(self, context):
return fill_size_drop_down(cTB, self.asset_id)
def _fill_lod_drop_down(self, context):
# Get list of locally available sizes
local_lods = []
local_lods = cTB._asset_index.check_asset_local_lods(
self.asset_id, ModelType.FBX)
items_lod = [
("NONE",
LOD_NAME.format("NONE", LOD_DESCS["NONE"]),
_t("Import the med. poly level of detail (LOD) .blend file"))
]
for _lod, is_local in local_lods.items():
if not is_local:
continue
# Tuple: (id, name, description[, icon, [enum value]])
lod_tuple = (_lod,
LOD_NAME.format(_lod, LOD_DESCS[_lod]),
LOD_DESCRIPTION_FBX.format(LOD_DESCS[_lod]))
# Note: Usually we rather do a list(set()) afterwards,
# but in this case order is important!
if lod_tuple not in items_lod:
items_lod.append(lod_tuple)
return items_lod
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
do_use_collection: BoolProperty(
name=_t("Import as collection"), # noqa: F722
description=_t("Instance model from a reusable collection"), # noqa: F722
default=False
)
do_reuse_materials: BoolProperty(
name=_t("Reuse materials"), # noqa: F722
description=_t("Reuse already imported materials to avoid duplicates"), # noqa: F722
default=False)
do_link_blend: BoolProperty(
name=_t("Link .blend file"), # noqa: F722
description=_t("Link the .blend file instead of appending"), # noqa: F722
default=False)
size: EnumProperty(
name=_t("Texture"), # noqa: F821
items=_fill_size_drop_down,
description=_t("Change size of assigned textures.")) # noqa: F722
lod: EnumProperty(
name=_t("LOD"), # noqa: F821
items=_fill_lod_drop_down,
description=_t("Change LOD of the Model.")) # noqa: F722
def __init__(self, *args, **kwargs):
"""Runs once per operator call before drawing occurs."""
super().__init__(*args, **kwargs)
# Infer the default value on each press from the cached session
# setting, which will be updated by redo last but not saved to prefs.
self.do_link_blend = cTB.link_blend_session
self.blend_exists = False
self.lod_import = False
self.asset_data = None
self.exec_count = 0
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
def draw(self, context):
prefer_blend = cTB.settings["download_prefer_blend"] and self.blend_exists
if not self.blend_exists:
label = _t("No local .blend file :")
row_link_enabled = False
row_sizes_enabled = True
elif not prefer_blend:
label = _t("Enable preference 'Download + Import .blend' :")
row_link_enabled = False
row_sizes_enabled = True
elif self.lod_import:
label = _t("Set 'LOD' to 'NONE' to load .blend :")
row_link_enabled = False
row_sizes_enabled = True
else:
label = None
row_link_enabled = True
row_sizes_enabled = not self.do_link_blend
row = self.layout.row()
row.prop(self, "lod")
row.enabled = True
row = self.layout.row()
row.prop(self, "size")
row.enabled = row_sizes_enabled
self.layout.prop(self, "do_use_collection")
row = self.layout.row()
row.prop(self, "do_reuse_materials")
row.enabled = not (row_link_enabled and self.do_link_blend)
if label is not None:
self.layout.label(text=label)
row = self.layout.row()
row.prop(self, "do_link_blend")
row.enabled = row_link_enabled
@reporting.handle_operator()
def execute(self, context):
"""Runs at least once before first draw call occurs."""
self.asset_data = cTB._asset_index.get_asset(self.asset_id)
asset_name = self.asset_data.asset_name
# Save any updated preference (to link or not), if changed via the
# redo last menu (without changing the saved preferences value).
cTB.link_blend_session = self.do_link_blend
project_files, textures, size, lod = self.get_model_data(
self.asset_data)
asset_name = construct_model_name(asset_name, size, lod)
did_fresh_import = False
inst = None
new_objs = []
# Import the model.
blend_import = False
coll_exists = bpy.data.collections.get(asset_name) is not None
if self.do_use_collection is False or not coll_exists:
ok, new_objs, blend_import, fbx_fail = self.run_fresh_import(
context,
project_files,
textures,
size,
lod)
if fbx_fail:
return {'CANCELLED'}
did_fresh_import = True
# If imported, perform these steps regardless of collection or not.
if did_fresh_import:
empty = self.setup_empty_parent(context, new_objs, asset_name)
if blend_import and not self.do_use_collection:
empty.location = bpy.context.scene.cursor.location
else:
empty = None
if self.do_use_collection is True:
# Move the objects into a subcollection, and place in scene.
if did_fresh_import:
# Always create a new collection if did a fresh import.
cache_coll = bpy.data.collections.new(asset_name)
# Ensure all objects are only part of this new collection.
for _obj in [empty] + new_objs:
for _collection in _obj.users_collection:
_collection.objects.unlink(_obj)
cache_coll.objects.link(_obj)
# Now add the cache collection to the layer, but unchecked.
layer = context.view_layer.active_layer_collection
layer.collection.children.link(cache_coll)
for _child in layer.children:
if _child.collection == cache_coll:
_child.exclude = True
else:
cache_coll = bpy.data.collections.get(asset_name)
# Now finally add the instance to the scene.
if cache_coll:
inst = self.create_instance(context, cache_coll, size, lod)
# layer = context.view_layer.active_layer_collection
# layer.collection.objects.link(inst)
else:
err = _t("Failed to get new collection to instance")
self.report({"ERROR"}, err)
return {"CANCELLED"}
elif not blend_import:
# Make sure the objects imported are all part of the same coll.
self.append_cleanup(context, empty)
# Final notifications and reporting.
cTB.f_GetSceneAssets()
if blend_import:
# Fix import info message containing LOD info
asset_name = construct_model_name(asset_name, size, "")
if did_fresh_import is True:
fmt = "blend" if blend_import else "FBX"
self.report(
{"INFO"},
_t("Model Imported ({0}) : {1}").format(fmt, asset_name))
elif self.do_use_collection and inst is not None:
self.report(
{"INFO"}, _t("Instance created : ").format(inst.name))
else:
err = _t(
"Failed to import model correctly: {0}").format(asset_name)
self.report({"ERROR"}, err)
err = f"Failed to import model correctly: {asset_name}"
reporting.capture_message("import-model-failed", err, "error")
return {"CANCELLED"}
if self.exec_count == 0:
cTB.signal_import_asset(asset_id=self.asset_id)
self.exec_count += 1
return {"FINISHED"}
def _filter_lod_fbxs(self,
file_list: List[str]
) -> List[str]:
"""Returns a list with all FBX files with LOD tag in filename"""
all_lod_fbxs = []
for asset_path in file_list:
if f_FExt(asset_path) != ".fbx":
continue
filename_parts = f_FName(asset_path).split("_")
for lod_level in LODS:
if lod_level not in filename_parts:
continue
all_lod_fbxs.append(asset_path)
break
return all_lod_fbxs
def get_model_data(self, asset_data: AssetData):
asset_type_data = asset_data.get_type_data()
# Get the intended material size and LOD import to use.
size_desired = self.size if self.size is not None else cTB.settings["mres"]
size = asset_type_data.get_size(
size_desired,
local_only=True,
addon_convention=cTB._asset_index.addon_convention,
local_convention=asset_data.get_convention(local=True))
prefer_blend = cTB.settings["download_prefer_blend"]
lod = self.lod
if lod == "NONE":
if not prefer_blend:
lod = "LOD1"
else:
lod = cTB.settings["lod"]
lod = asset_type_data.get_lod(lod)
if lod == "NONE":
lod = None
files = {}
asset_type_data.get_files(files)
files = list(files.keys())
all_lod_fbxs = self._filter_lod_fbxs(files)
files_lod_fbx = [
_file for _file in all_lod_fbxs
if f_FExt(_file) == ".fbx" and str(lod) in f_FName(_file).split("_")
]
# Most likely redundant, but rather safe than sorry
files_lod_fbx = list(set(files_lod_fbx))
files_fbx = []
files_blend = []
for _file in files:
filename_ext = f_FExt(_file)
is_fbx = filename_ext == ".fbx"
is_blend = filename_ext == ".blend" and "_LIB.blend" not in _file
if not is_fbx and not is_blend:
continue
if is_fbx and lod is not None and len(files_lod_fbx):
if _file not in files_lod_fbx:
continue
if is_fbx and lod is None and _file in all_lod_fbxs:
continue
if is_fbx:
files_fbx.append(_file)
elif is_blend:
files_blend.append(_file)
cTB.logger.debug("POLIIGON_OT_model get_model_data "
f"{os.path.basename(_file)}")
self.blend_exists = len(files_blend) > 0
self.lod_import = lod is not None and self.lod != "NONE"
if prefer_blend and self.blend_exists and not self.lod_import:
files_project = files_blend
elif not prefer_blend and not files_fbx and self.blend_exists:
# Settings specify to import fbx files, but only blend exists.
# Needed for asset browser imports and right-click
# TODO(Patrick): Migrate to using a use_blend operator arg, so it
# can also be a backdoor option.
files_project = files_blend
else:
files_project = files_fbx
files_tex = []
for _file in files:
if f_FExt(_file) not in MAP_EXT_LOWER:
continue
if size not in f_FName(_file).split("_"):
continue
if any(
_lod in f_FName(_file).split("_") for _lod in LODS
) and lod not in f_FName(_file).split("_"):
continue
files_tex.append(_file)
return files_project, files_tex, size, lod
def _load_blend(self, path_proj: str):
"""Loads all objects from a .blend file."""
path_proj_norm = os.path.normpath(path_proj)
with bpy.data.libraries.load(path_proj_norm,
link=self.do_link_blend
) as (data_from,
data_to):
data_to.objects = data_from.objects
return data_to.objects
def _cut_identity_counter(self, s: str) -> str:
"""Reduces strings like 'walter.042' to 'walter'"""
splits = s.rsplit(".", maxsplit=1)
if len(splits) > 1 and splits[1].isdecimal():
s = splits[0]
return s
def _reuse_materials(self, imported_objs: List, imported_mats: List):
"""Re-uses previously imported materials after a .blend import"""
if not self.do_reuse_materials or self.do_link_blend:
return
# Mark all materials from this import
PROP_FRESH_IMPORT = "poliigon_fresh_import"
for mat in imported_mats:
mat[PROP_FRESH_IMPORT] = True
# Find any previously imported materials with same name
# and make the objects use those
mats_remap = [] # list of tuples (from_mat, to_mat)
for obj in imported_objs:
mat_on_obj = obj.active_material
if mat_on_obj is None:
continue
materials = reversed(sorted(list(bpy.data.materials.keys())))
for name_mat in materials:
# Unfortunately we seem to have little control,
# where and when Blender adds counter suffixes for
# identically named materials.
# Therefore we compare names without any counter suffix.
name_on_obj_cmp = self._cut_identity_counter(mat_on_obj.name)
name_mat_cmp = self._cut_identity_counter(name_mat)
if name_on_obj_cmp != name_mat_cmp:
continue
mat_reuse = bpy.data.materials[name_mat]
is_fresh = mat_reuse.get(PROP_FRESH_IMPORT, False)
if is_fresh:
continue
if (mat_on_obj, mat_reuse) not in mats_remap:
mats_remap.append((mat_on_obj, mat_reuse))
break
# Remove previously added marker
for mat in imported_mats:
if PROP_FRESH_IMPORT in mat.keys():
del mat[PROP_FRESH_IMPORT]
# Finally remap the materials and remove those freshly imported ones
did_send_sentry = False
for from_mat, to_mat in mats_remap:
from_mat.user_remap(to_mat)
from_mat.user_clear()
if from_mat in imported_mats:
imported_mats.remove(from_mat)
if from_mat.users != 0 and not did_send_sentry:
msg = ("User count not zero on material replaced by reuse: "
f"Asset: {self.asset_id}, Material: {from_mat.name}")
reporting.capture_message(
"import_model_mat_reuse_user_count",
msg,
"info")
did_send_sentry = True
continue
try:
bpy.data.materials.remove(from_mat)
except Exception as e:
reporting.capture_exception(e)
self.report({"WARNING"},
_t("Failed to remove material after reuse."))
# TODO(Andreas): replace usage with AssetIndex/AssetData alternative
@staticmethod
def f_GetVar(name: str) -> str:
for _variant in VARIANTS:
if _variant in name:
return _variant
return None
def run_fresh_import(self,
context,
project_files: List[str],
files_tex: List[str],
size: str,
lod: str
) -> Tuple[bool,
List[bpy.types.Object],
bool,
bool]:
"""Performs a fresh import of the whole model.
There can be multiple FBX models, therefore we want to import all of
them and verify each one was properly imported.
Return values:
Tuple[0] - True, if all FBX files have been imported successfully
Tuple[1] - List of all imported mesh objects
Tuple[2] - True, if it was a .blend import instead of an FBX import
Tuple[3] - True, if an error occurred during FBX loading
"""
PROP_LIBRARY_LINKED = "poliigon_linked"
asset_name = self.asset_data.asset_name
meshes_all = []
dict_mats_imported = {}
imported_proj = []
blend_import = False
for path_proj in project_files:
filename_base = f_FName(path_proj)
if not f_Ex(path_proj):
err = _t("Couldn't load project file: {0} {1}").format(
asset_name, path_proj)
self.report({"ERROR"}, err)
err = f"Couldn't load project file: {self.asset_id} {path_proj}"
reporting.capture_message("model_fbx_missing", err, "info")
continue
list_objs_before = list(context.scene.objects)
ext_proj = f_FExt(path_proj)
if ext_proj == ".blend":
cTB.logger.debug("POLIIGON_OT_model BLEND IMPORT")
filename = filename_base + ".blend"
if self.do_link_blend and filename in bpy.data.libraries.keys():
lib = bpy.data.libraries[filename]
if lib[PROP_LIBRARY_LINKED]:
linked_objs = []
for obj in bpy.data.objects:
if obj.library == lib:
linked_objs.append(obj)
imported_objs = []
for obj in linked_objs:
imported_objs.append(obj.copy())
else:
imported_objs = self._load_blend(path_proj)
else:
imported_objs = self._load_blend(path_proj)
if filename in bpy.data.libraries.keys():
lib = bpy.data.libraries[filename]
lib[PROP_LIBRARY_LINKED] = self.do_link_blend
for obj in context.view_layer.objects:
obj.select_set(False)
layer = context.view_layer.active_layer_collection
imported_mats = []
for obj in imported_objs:
if obj is None:
continue
obj_copy = obj.copy()
layer.collection.objects.link(obj_copy)
obj_copy.select_set(True)
if obj_copy.active_material is None:
pass
elif obj_copy.active_material not in imported_mats:
imported_mats.append(obj_copy.active_material)
files_dict = {}
asset_type_data = self.asset_data.get_type_data()
asset_type_data.get_files(files_dict)
asset_files = list(files_dict.keys())
replace_tex_size(
imported_mats,
asset_files,
size,
self.do_link_blend
)
self._reuse_materials(imported_objs, imported_mats)
blend_import = True
else:
cTB.logger.debug("POLIIGON_OT_model FBX IMPORT")
if "fbx" not in dir(bpy.ops.import_scene):
try:
bpy.ops.preferences.addon_enable(module="io_scene_fbx")
self.report(
{"INFO"},
_t("FBX importer addon enabled for import")
)
except RuntimeError:
self.report(
{"ERROR"},
_t("Built-in FBX importer could not be found, check Blender install")
)
did_full_import = False
meshes_all = []
blend_import = False
fbx_error = True
return (did_full_import,
meshes_all,
blend_import,
fbx_error)
try:
# Note on use_custom_normals parameter:
# It always defaulted to True. But in Blender 4.0+
# it started to cause issues.
# Mateusz sync'ed with Stephen and recommended to turn it
# off regardless of Blender version.
bpy.ops.import_scene.fbx(filepath=path_proj,
axis_up="-Z",
use_custom_normals=False)
except Exception as e:
self.report({"ERROR"},
_t("FBX importer exception:") + str(e))
did_full_import = False
meshes_all = []
blend_import = False
fbx_error = True
return (did_full_import,
meshes_all,
blend_import,
fbx_error)
imported_proj.append(path_proj)
vMeshes = [_obj for _obj in list(context.scene.objects)
if _obj not in list_objs_before]
meshes_all += vMeshes
if ext_proj == ".blend":
for _mesh in vMeshes:
# Ensure we can identify the mesh & LOD even on name change
# TODO(Andreas): Remove old properties?
_mesh.poliigon = f"Models;{asset_name}"
if lod is not None:
_mesh.poliigon_lod = lod
self.set_poliigon_props_model(_mesh, lod)
continue
for _mesh in vMeshes:
if _mesh.type == "EMPTY":
continue
name_mat_imported = ""
if _mesh.active_material is not None:
name_mat_imported = _mesh.active_material.name
# Note: Of course the check if "_mat" is contained could be
# written in one line. But I wouldn't consider "_mat"
# unlikely in arbitrary filenames. Thus I chose to
# explicitly compare for "_mat" at the end and
# additionally check if "_mat_" is contained, in order
# to at least reduce the chance of false positives a bit.
name_mat_imported_lower = name_mat_imported.lower()
name_tex_remastered = ""
name_tex_on_obj = ""
ends_remastered = name_mat_imported_lower.endswith("_mat")
contains_remastered = "_mat_" in name_mat_imported_lower
if ends_remastered or contains_remastered:
pos_remastered = name_mat_imported_lower.rfind("_mat", 1)
name_tex_remastered = name_mat_imported[:pos_remastered]
else:
name_tex_on_obj = name_mat_imported.split("_")[0]
name_mesh_base = _mesh.name.split(".")[0].split("_")[0]
variant_mesh = self.f_GetVar(_mesh.name)
variant_name = variant_mesh
if variant_mesh is None:
# This is a fallback for models,
# where the object name does not contain a variant indicator.
# As None is covered explicitly in the loop below,
# this should do no harm.
variant_mesh = "VAR1"
variant_name = ""
name_mat = name_mesh_base
files_tex_filtered = []
if len(name_tex_remastered) > 0:
# Remastered textures
files_tex_filtered = [
_file
for _file in files_tex
if os.path.basename(_file).startswith(
name_tex_remastered)
]
name_mat = name_mat_imported
else:
for vCheck in [name_mesh_base,
filename_base.split("_")[0],
asset_name,
name_tex_on_obj]:
files_tex_filtered = [
_file
for _file in files_tex
if os.path.basename(_file).startswith(vCheck)
if self.f_GetVar(f_FName(_file)) in [None,
variant_mesh]
]
name_mat = vCheck
if len(files_tex_filtered) > 0:
break
if len(files_tex_filtered) == 0:
err = f"No Textures found for: {self.asset_id} {_mesh.name}"
reporting.capture_message(
"model_texture_missing", err, "info")
continue
name_mat += f"_{size}"
if size not in name_mat:
name_mat += f"_{size}"
if variant_name != "":
name_mat += f"_{variant_name}"
# TODO(Andreas): Not sure, why these lines are commented out.
# Looks reasonable to me.
# if name_mat in bpy.data.materials and self.do_reuse_materials:
# mat = bpy.data.materials[name_mat]
# el
# TODO(Andreas): Not sure, this should also be dependening on self.do_reuse_materials?
if name_mat in dict_mats_imported.keys():
# Already built in previous iteration
mat = dict_mats_imported[name_mat]
else:
mat = cTB.mat_import.import_material(
asset_data=self.asset_data,
do_apply=False,
workflow="METALNESS",
size=size,
size_bg=None,
lod=lod,
variant=variant_name if variant_name != "" else None,
name_material=name_mat,
name_mesh=name_mesh_base,
ref_objs=None,
projection="UV",
use_16bit=True,
mode_disp="NORMAL", # We never want model dispalcement
translate_x=0.0,
translate_y=0.0,
scale=1.0,
global_rotation=0.0,
aspect_ratio=1.0,
displacement=0.0,
keep_unused_tex_nodes=False,
reuse_existing=self.do_reuse_materials
)
if mat is None:
msg = f"{self.asset_id}: Failed to build matrial: {name_mat}"
reporting.capture_message(
"could_not_create_fbx_mat", msg, "error")
self.report(
{"ERROR"}, _t("Material could not be created."))
imported_proj.remove(path_proj)
break
dict_mats_imported[name_mat] = mat
# This sequence is important!
# 1) Setting the material slot to None
# 2) Changing the link mode
# 3) Assigning our generated material
# Any other order of these statements will get us into trouble
# one way or another.
_mesh.active_material = None
if len(_mesh.material_slots) > 0:
_mesh.material_slots[0].link = "OBJECT"
_mesh.active_material = mat
if variant_mesh is not None:
if len(_mesh.material_slots) == 0:
_mesh.data.materials.append(mat)
else:
_mesh.material_slots[0].link = "OBJECT"
_mesh.material_slots[0].material = mat
_mesh.material_slots[0].link = "OBJECT"
# Ensure we can identify the mesh & LOD even on name change.
# TODO(Andreas): Remove old properties?
_mesh.poliigon = f"Models;{asset_name}"
if lod is not None:
_mesh.poliigon_lod = lod
self.set_poliigon_props_model(_mesh, lod)
# Finally try to remove the originally imported materials
if name_mat_imported in bpy.data.materials:
mat_imported = bpy.data.materials[name_mat_imported]
if mat_imported.users == 0:
mat_imported.user_clear()
bpy.data.materials.remove(mat_imported)
# There could have been multiple FBXs, consider fully imported
# for user-popup reporting if all FBX files imported.
did_full_import = len(imported_proj) == len(project_files)
return did_full_import, meshes_all, blend_import, False
def set_poliigon_props_model(
self, obj: bpy.types.Object, lod: str) -> None:
"""Sets Poliigon properties on an imported object."""
obj.poliigon_props.asset_id = self.asset_data.asset_id
obj.poliigon_props.asset_name = self.asset_data.asset_name
obj.poliigon_props.asset_type = self.asset_data.asset_type.name
obj.poliigon_props.size = self.size
if lod is not None:
obj.poliigon_props.lod = lod
obj.poliigon_props.use_collection = self.do_use_collection
obj.poliigon_props.link_blend = self.do_link_blend
def object_has_children(self, obj_parent: bpy.types.Object) -> bool:
"""Returns True, if obj_parent has children."""
for _obj in bpy.data.objects:
if _obj.parent == obj_parent:
return True
return False
def setup_empty_parent(self,
context,
new_meshes: List[bpy.types.Object],
asset_name: str
) -> bpy.types.Object:
"""Parents newly imported objects to a central empty object."""
radius = 0
for _mesh in new_meshes:
dimensions = _mesh.dimensions
if dimensions.x > radius:
radius = dimensions.x
if dimensions.y > radius:
radius = dimensions.y
empty = bpy.data.objects.new(
name=f"{asset_name}_Empty", object_data=None)
if self.do_use_collection:
empty.empty_display_size = 0.01
else:
empty.empty_display_size = radius * 0.5
layer = context.view_layer.active_layer_collection
layer.collection.objects.link(empty)
for _mesh in new_meshes.copy():
if _mesh.type == "EMPTY" and not self.object_has_children(_mesh):
bpy.data.objects.remove(_mesh, do_unlink=True)
new_meshes.remove(_mesh)
continue
_mesh.parent = empty
return empty
def create_instance(self, context, coll, size, lod):
"""Creates an instance of an existing collection int he active view."""
asset_name = self.asset_data.asset_name
inst_name = construct_model_name(asset_name, size, lod) + "_Instance"
inst = bpy.data.objects.new(name=inst_name, object_data=None)
inst.instance_collection = coll
inst.instance_type = "COLLECTION"
lc = context.view_layer.active_layer_collection
lc.collection.objects.link(inst)
inst.location = context.scene.cursor.location
inst.empty_display_size = 0.01
# Set selection and active object.
for _obj in context.scene.collection.all_objects:
_obj.select_set(False)
inst.select_set(True)
context.view_layer.objects.active = inst
return inst
def append_cleanup(self, context, root_empty):
"""Performs selection and placement cleanup after an import/append."""
if not root_empty:
cTB.logger.error("root_empty was not a valid object, exiting cleanup")
return
# Set empty location
root_empty.location = context.scene.cursor.location
# Deselect all others in scene (faster than using operator call).
for _obj in context.scene.collection.all_objects:
_obj.select_set(False)
# Make empty active and select it + children.
root_empty.select_set(True)
context.view_layer.objects.active = root_empty
for _obj in root_empty.children:
_obj.select_set(True)
@@ -0,0 +1,73 @@
# #### 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
from bpy.types import Operator
from bpy.props import StringProperty
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_notice_operator(Operator):
bl_idname = "poliigon.notice_operator"
bl_label = ""
bl_options = {"INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
notice_id: StringProperty(options={"HIDDEN"}) # noqa: F821
ops_name: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@reporting.handle_operator(silent=True)
def execute(self, context):
# Execute the operator via breaking into parts e.g. "wm.quit_blender"
atr = self.ops_name.split(".")
if len(atr) != 2:
reporting.capture_message("bad_notice_operator", self.ops_name)
return {'CANCELLED'}
# Safeguard to avoid injection.
if self.ops_name not in ("wm.quit_blender"):
cTB.logger.error("POLIIGON_OT_notice_operator Unsupported "
f"operation: {self.ops_name}")
return {'CANCELLED'}
notice = cTB.notify.get_top_notice(do_signal_view=False)
if notice is not None:
cTB.notify.clicked_notice(notice)
cTB.logger.debug(
f"POLIIGON_OT_notice_operator Running {self.ops_name}")
# Using invoke acts like in the interface, so any "save?" dialogue
# will pick up, for instance if a "quit" operator.
getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
if notice is not None:
cTB.notify.dismiss_notice(notice)
return {'FINISHED'}
@@ -0,0 +1,96 @@
# #### 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 webbrowser
from bpy.types import Operator
from bpy.props import IntProperty, StringProperty
from ..modules.poliigon_core.assets import AssetType
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from ..utils import open_dir
from .. import reporting
class POLIIGON_OT_options(Operator):
bl_idname = "poliigon.poliigon_asset_options"
bl_label = ""
bl_description = _t("Asset Options")
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"REGISTER", "INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
mode: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@reporting.handle_operator()
def execute(self, context):
global cTB
asset_data = cTB._asset_index.get_asset(self.asset_id)
asset_name = asset_data.asset_name
asset_type_data = asset_data.get_type_data()
dict_asset_files = {}
asset_type_data.get_files(dict_asset_files)
asset_files = list(dict_asset_files.keys())
if self.mode == "dir":
directories = sorted(
list(set([os.path.dirname(_file)
for _file in asset_files]))
)
for idx_dir in range(len(directories)):
if asset_name in directories[idx_dir]:
directories[idx_dir] = directories[idx_dir].split(
asset_name)[0] + asset_name
directories = sorted(list(set(directories)))
for _dir in directories:
open_dir(_dir)
elif self.mode == "link":
asset_type = asset_data.asset_type
url_name = asset_name
url_name = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", url_name)
url_name = (
(re.sub(r"(?<=[a-z])(?=[0-9])", " ", url_name))
.lower()
.replace(" ", "-")
)
if asset_type == AssetType.TEXTURE:
url = f"https://www.poliigon.com/texture/{url_name}"
elif asset_type == AssetType.MODEL:
url = f"https://www.poliigon.com/model/{url_name}"
webbrowser.open(url)
return {"FINISHED"}
@@ -0,0 +1,295 @@
# #### 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
from bpy.props import StringProperty
from bpy.types import Operator
from ..modules.poliigon_core.api import TIMEOUT
from ..modules.poliigon_core.multilingual import _t
from ..modules.poliigon_core.upgrade_content import UpgradeContent
from ..dialogs.utils_dlg import get_ui_scale, wrapped_label
from ..constants import POPUP_WIDTH_LABEL
from ..toolbox import (
get_context,
t_check_change_plan_response)
from .. import reporting
# A bit longer than API timeouts
TIMEOUT_CHANGE_PLAN = TIMEOUT + 1.0
class POLIIGON_OT_popup_change_plan(Operator):
"""This operator provides the 'subscription plan update' details popup
dialog.
"""
bl_idname = "poliigon.popup_change_plan"
bl_label = _t("Change Plan")
bl_options = {"INTERNAL", "REGISTER", "UNDO"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
def invoke(self, context, event):
cTB.plan_upgrade_in_progress = False
cTB.plan_upgrade_finished = False
cTB.msg_plan_upgrade_finished = None
cTB.upgrade_manager.emit_signal(clicked=True)
return context.window_manager.invoke_props_popup(self, event)
def _draw_plan_details(
self,
layout: bpy.types.UILayout,
upgrade_content: UpgradeContent
) -> None:
"""Draws the plan details table."""
upgrade_popup_table = upgrade_content.upgrade_popup_table
upgrade_popup_key_value = upgrade_content.upgrade_popup_key_value
if upgrade_popup_table is None and upgrade_popup_key_value is None:
return
row = layout.row()
col_left = row.column(align=True)
col_right = row.column(align=True)
if upgrade_popup_table is not None:
for _label, _value in upgrade_popup_table.items():
col_left.label(text=_label)
col_right.label(text=_value)
col_left.label(text=_t(" "))
col_right.label(text=_t(" "))
if upgrade_popup_key_value is not None:
for _label, _value in upgrade_popup_key_value.items():
col_left.label(text=_label)
col_right.label(text=_value)
layout.separator()
def _draw_note(
self,
layout: bpy.types.UILayout,
label_width: float,
upgrade_content: UpgradeContent
) -> None:
"""Draws a text note (e.g. tax advice...)."""
text = upgrade_content.upgrade_popup_text
if text is None:
return
row = layout.row()
col = row.column(align=True)
wrapped_label(
cTB,
width=label_width,
text=text,
container=col,
add_padding_bottom=True)
def _draw_button_change_plan(
self,
layout: bpy.types.UILayout,
upgrade_content: UpgradeContent
) -> None:
"""Draws the confirmation button to actually change the subscription
plan.
"""
label = upgrade_content.upgrade_popup_confirm_button
row_button = layout.row()
row_button.operator_context = 'INVOKE_DEFAULT'
op = row_button.operator(
"poliigon.change_plan", text=label)
op.tooltip = _t("Confirm the change of your subscription plan")
def _draw_legal(
self,
layout: bpy.types.UILayout,
upgrade_content: UpgradeContent
) -> None:
"""Draws legal web links."""
row = layout.row()
col = row.column(align=True)
if upgrade_content.upgrade_popup_pricing_button is not None:
label = upgrade_content.upgrade_popup_pricing_button
op_pricing = col.operator("poliigon.poliigon_link",
text=label,
emboss=False)
op_pricing.tooltip = _t("View all pricing details online")
op_pricing.mode = "subscribe"
if upgrade_content.upgrade_popup_terms_button is not None:
label = upgrade_content.upgrade_popup_terms_button
op_terms = col.operator("poliigon.poliigon_link",
text=label,
emboss=False)
op_terms.tooltip = _t("View our terms & policy documents online")
op_terms.mode = "terms_policy"
@reporting.handle_draw()
def draw(self, context):
label_width = POPUP_WIDTH_LABEL * get_ui_scale(cTB)
# Accounting for the left+right border columns (eyeballing):
if bpy.app.version >= (4, 0):
label_width -= 10.0
elif bpy.app.version >= (3, 0):
# TODO(Andreas): Scale for other versions and in other popups, too?
label_width -= 25.0 * get_ui_scale(cTB)
else:
label_width -= 10.0
col_content = self.layout.column()
upgrade_content = cTB.upgrade_manager.content
self._draw_plan_details(col_content, upgrade_content)
self._draw_note(col_content, label_width, upgrade_content)
self._draw_button_change_plan(col_content, upgrade_content)
self._draw_legal(col_content, upgrade_content)
@reporting.handle_operator(silent=True)
def execute(self, context):
return {'FINISHED'}
class POLIIGON_OT_banner_change_plan_dismiss(Operator):
"""This operator provides the functionality to dismiss any
'subscription plan update' banner (if the banner type allows dismissal).
"""
bl_idname = "poliigon.popup_change_plan_dismiss"
bl_label = _t("Dismiss upgrade notice")
bl_options = {"INTERNAL", "REGISTER"}
bl_description = _t("Dismiss upgrade notice")
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return cls.bl_description
@reporting.handle_operator(silent=True)
def execute(self, context):
cTB.upgrade_manager.dismiss_upgrade()
cTB.refresh_ui()
return {'FINISHED'}
class POLIIGON_OT_banner_finish_dismiss(Operator):
"""This operator provides the functionality to dismiss the final
success/error banner of a 'subscription plan update'.
"""
bl_idname = "poliigon.banner_finish_dismiss"
bl_label = _t("Dismiss this message")
bl_options = {"INTERNAL", "REGISTER"}
bl_description = _t("Dismiss this message")
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return cls.bl_description
@reporting.handle_operator(silent=True)
def execute(self, context):
cTB.plan_upgrade_in_progress = False
cTB.plan_upgrade_finished = False
cTB.msg_plan_upgrade_finished = None
cTB.refresh_ui()
return {'FINISHED'}
class POLIIGON_OT_change_plan(Operator):
"""This operator (usually triggered by button in 'change plan' popup)
executes the actual change of the subscription plan.
"""
bl_idname = "poliigon.change_plan"
bl_label = _t("Change Plan")
bl_options = {"INTERNAL", "REGISTER"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@reporting.handle_draw()
def draw(self, context):
return
@reporting.handle_operator(silent=True)
def execute(self, context):
# Close the 'change plan' popup
# (yeah, I know, but it indeed works...)
# From: https://blender.stackexchange.com/questions/202550/close-a-popup-with-an-op-call
context.window.screen = context.window.screen
cTB.plan_upgrade_in_progress = True
cTB.upgrade_manager.finish_upgrade_plan()
# Just to make sure, the progress banner does not stay on,
# even if hell freezes over.
if bpy.app.timers.is_registered(t_check_change_plan_response):
bpy.app.timers.unregister(t_check_change_plan_response)
bpy.app.timers.register(
t_check_change_plan_response,
first_interval=TIMEOUT_CHANGE_PLAN,
persistent=True)
cTB.refresh_ui()
return {'FINISHED'}
@@ -0,0 +1,79 @@
# #### 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 bpy.types import Operator
from bpy.props import BoolProperty, StringProperty
from ..modules.poliigon_core.multilingual import _t
from ..dialogs.utils_dlg import (
get_ui_scale,
wrapped_label)
from ..constants import (
POPUP_WIDTH_LABEL_WIDE,
POPUP_WIDTH_WIDE)
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_popup_download_limit(Operator):
bl_idname = "poliigon.popup_download_limit"
bl_label = _t("Unlimited Fair Use Exceeded")
bl_options = {"INTERNAL"}
msg: StringProperty(options={"HIDDEN"}, default="") # noqa: F821, F722
force: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
def invoke(self, context, event):
if not cTB.is_unlimited_user():
# We shouldn't even be here. Just a safety to never show this
# to non-unlimited users.
return {'FINISHED'}
cTB.signal_popup(popup="DOWNLOAD_RATE_LIMITED")
return context.window_manager.invoke_props_dialog(
self, width=POPUP_WIDTH_WIDE)
@reporting.handle_draw()
def draw(self, context):
label_width = POPUP_WIDTH_LABEL_WIDE * get_ui_scale(cTB)
col_content = self.layout.column()
wrapped_label(
cTB,
width=label_width,
text=self.msg,
container=col_content,
add_padding_bottom=True)
op = col_content.operator(
"poliigon.poliigon_link", text=_t("Learn More"))
op.mode = "unlimited_plan_help"
op.tooltip = _t("See more info about Unlimited Fair Use policy on our "
"website.")
@reporting.handle_operator(silent=True)
def execute(self, context):
cTB.refresh_ui()
return {'FINISHED'}
@@ -0,0 +1,79 @@
# #### 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
from bpy.types import Operator
from bpy.props import StringProperty
from ..dialogs.utils_dlg import (
get_ui_scale,
wrapped_label)
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_popup_message(Operator):
bl_idname = "poliigon.popup_message"
bl_label = ""
bl_options = {"INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
message_body: StringProperty(options={"HIDDEN"}) # noqa: F821
message_url: StringProperty(options={"HIDDEN"}) # noqa: F821
notice_id: StringProperty(options={"HIDDEN"}) # noqa: F821
target_width = 400
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
def invoke(self, context, event):
width = self.target_width # Blender handles scaling to ui.
notice = cTB.notify.get_top_notice(do_signal_view=False)
if notice is not None:
cTB.notify.clicked_notice(notice)
return context.window_manager.invoke_props_dialog(self, width=width)
@reporting.handle_draw()
def draw(self, context):
layout = self.layout
target_wrap = self.target_width * get_ui_scale(cTB)
target_wrap -= 25 * get_ui_scale(cTB)
wrapped_label(cTB, target_wrap, self.message_body, layout)
@reporting.handle_operator(silent=True)
def execute(self, context):
if self.message_url:
bpy.ops.wm.url_open(url=self.message_url)
notice = cTB.notify.get_top_notice(do_signal_view=False)
if notice is not None:
cTB.notify.dismiss_notice(notice)
cTB.refresh_ui()
return {'FINISHED'}
@@ -0,0 +1,176 @@
# #### 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 bpy
from bpy.types import Operator
from bpy.props import BoolProperty
from ..modules.poliigon_core.multilingual import _t
from ..dialogs.utils_dlg import get_ui_scale, wrapped_label
from ..constants import (
POPUP_WIDTH_LABEL,
POPUP_WIDTH_NARROW,
POPUP_WIDTH_LABEL_NARROW)
from ..toolbox import get_context
from ..toolbox_settings import save_settings
from ..utils import load_image
from .. import reporting
class POLIIGON_OT_popup_welcome(Operator):
bl_idname = "poliigon.popup_welcome"
bl_label = _t("Welcome to the Poliigon Addon")
bl_options = {"INTERNAL", "REGISTER", "UNDO"}
force: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
def _load_images(self) -> None:
path = os.path.join(cTB.dir_script, "onboarding_welcome.png")
self.img_welcome = load_image("POPUP_welcome", path)
def invoke(self, context, event):
if cTB.is_unlimited_user():
return {'FINISHED'}
if cTB.settings["popup_welcome"] and not self.force:
return {'FINISHED'}
self._load_images()
cTB.settings["popup_welcome"] = 1
save_settings(cTB)
return context.window_manager.invoke_props_dialog(
self, width=POPUP_WIDTH_NARROW)
@reporting.handle_draw()
def draw(self, context):
label_width = POPUP_WIDTH_LABEL_NARROW * get_ui_scale(cTB)
# Accounting for the left+right border columns (eyeballing):
if bpy.app.version >= (4, 0):
label_width -= 10.0
elif bpy.app.version >= (3, 0):
# TODO(Andreas): Scale for other versions and in other popups, too?
label_width -= 25.0 * get_ui_scale(cTB)
else:
label_width -= 10.0
col_content = self.layout.column()
col_image = col_content.column()
col_image.scale_y = 0.5
col_image.template_icon(
icon_value=self.img_welcome.preview.icon_id,
scale=18.0)
row_text = col_content.row()
if bpy.app.version >= (3, 0):
col_left_gap = row_text.column()
col_left_gap.alignment = "LEFT"
col_left_gap.label(text=" ")
col_text = row_text.column()
col_right_gap = row_text.column()
col_right_gap.alignment = "RIGHT"
col_right_gap.label(text=" ")
else:
col_left_gap = row_text.column()
col_left_gap.alignment = "LEFT"
# Note, here no label in left column. Otherwise we'd end up with a
# way too larger border gap.
col_text = row_text.column()
col_text.alignment = "CENTER"
wrapped_label(
cTB,
width=label_width,
text=_t('Preview any texture by clicking the "eye" icon.'),
container=col_text,
add_padding=True)
wrapped_label(
cTB,
width=label_width,
text=_t("Download, import and apply in just two clicks."),
container=col_text,
add_padding_bottom=True)
@reporting.handle_operator(silent=True)
def execute(self, context):
cTB.refresh_ui()
return {'FINISHED'}
class POLIIGON_OT_popup_first_download(Operator):
bl_idname = "poliigon.popup_first_download"
bl_label = _t("Purchased, download started")
bl_options = {"INTERNAL", "REGISTER", "UNDO"}
force: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
def invoke(self, context, event):
if cTB.is_unlimited_user():
return {'FINISHED'}
if cTB.settings["popup_download"] and not self.force:
return {'FINISHED'}
cTB.settings["popup_download"] = 1
cTB.signal_popup(popup="ONBOARD_PURCHASE")
save_settings(cTB)
return context.window_manager.invoke_props_popup(self, event)
@reporting.handle_draw()
def draw(self, context):
label_width = POPUP_WIDTH_LABEL * get_ui_scale(cTB)
col_content = self.layout.column()
wrapped_label(
cTB,
width=label_width,
text=_t("Thanks for purchasing your first Poliigon Asset!"),
container=col_content)
wrapped_label(
cTB,
width=label_width,
text=_t("By default Show Purchase Confirmation is disabled. You "
"can adjust this as well as your default download "
"settings in preferences."),
container=col_content,
add_padding=True)
op = col_content.operator(
"poliigon.open_preferences",
text="View Preferences"
)
op.set_focus = "show_default_prefs"
@reporting.handle_operator(silent=True)
def execute(self, context):
cTB.refresh_ui()
return {'FINISHED'}
@@ -0,0 +1,362 @@
# #### 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
from typing import List
import threading
import time
import bpy
from bpy.types import Operator
from bpy.props import (
BoolProperty,
IntProperty,
StringProperty)
import bpy.utils.previews
import bmesh
from ..modules.poliigon_core.api_remote_control import ApiJob
from ..modules.poliigon_core.multilingual import _t
from ..dialogs.utils_dlg import get_ui_scale, wrapped_label
from ..constants import POPUP_WIDTH_NARROW, POPUP_WIDTH_LABEL_NARROW
from ..toolbox import get_context
from ..toolbox_settings import save_settings
from ..utils import load_image
from .. import reporting
class POLIIGON_OT_preview(Operator):
"""Download and apply a watermarked version of this texture for previewing."""
bl_idname = "poliigon.poliigon_preview"
bl_label = _t("Texture Preview")
bl_description = _t("Download and apply a watermarked version of this texture for previewing")
bl_options = {"GRAB_CURSOR", "BLOCKING", "REGISTER", "INTERNAL", "UNDO"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
def __init__(self, *args, **kwargs):
"""Runs once per operator call before drawing occurs."""
super().__init__(*args, **kwargs)
self.asset_data = None
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@reporting.handle_operator()
def execute(self, context):
self.asset_data = cTB._asset_index.get_asset(self.asset_id)
asset_name = self.asset_data.asset_name
t_start = time.time()
files = self.download_preview(context)
t_downloaded = time.time()
if len(files) == 0:
self.report({'ERROR'}, _t("Failed to download preview files"))
struc_str = f"asset_name: {asset_name}"
reporting.capture_message(
"quick_preview_download_failed", struc_str, "error")
return {'CANCELLED'}
# Warn user on viewport change before post process, so that any later
# warnings will popup and take priority to be visible to user. Blender
# shows the last "self.report" message only (but all print to console).
self.report_viewport(context)
cTB.refresh_ui()
res = self.post_process_material(context, files)
t_post_processed = time.time()
total_time = t_post_processed - t_start
download_time = t_downloaded - t_start
debug_str = (f"Preview Total time: {total_time}, "
f"download: {download_time}")
cTB.logger.debug(f"POLIIGON_OT_preview {debug_str}")
cTB.signal_preview_asset(asset_id=self.asset_id)
return res
def _callback_download_wm_preview_done(self, job: ApiJob) -> None:
self.ev_download_done.set()
def download_preview(self, context) -> List[str]:
"""Download a preview and return expected files."""
asset_name = self.asset_data.asset_name
asset_type_data = self.asset_data.get_type_data()
self._name = f"PREVIEW_{asset_name}"
if len(asset_type_data.watermarked_urls):
bpy.context.window.cursor_set("WAIT")
self.ev_download_done = threading.Event()
cTB.api_rc.add_job_download_wm_preview(
self.asset_data,
renderer="Cycles", # TODO(Andreas)
callback_done=self._callback_download_wm_preview_done
)
self.ev_download_done.wait()
self.ev_download_done = None
workflow = asset_type_data.get_workflow("METALNESS")
tex_maps = asset_type_data.get_maps(workflow=workflow, size="WM")
files = [_tex_map.get_path() for _tex_map in tex_maps]
return files
else:
return []
@staticmethod
def create_plane(context,
scale: float = 5.0,
aspect_ratio: float = 1.0,
name: str = "Preview Plane",
do_select: bool = True
) -> bpy.types.Object:
"""Creates a new primitive plane object."""
mesh = bpy.data.meshes.new(name)
uv_layer = mesh.uv_layers.new()
mesh.uv_layers.active = uv_layer
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
bm = bmesh.new()
bm.from_mesh(mesh)
x = (scale / 2.0) * aspect_ratio
y = scale / 2.0
bm.verts.new((x, y, 0))
bm.verts.new((x, -y, 0))
bm.verts.new((-x, -y, 0))
bm.verts.new((-x, y, 0))
bm.verts.ensure_lookup_table()
# Have inverted vertex order for our normal to point upward
face = bm.faces.new(
(bm.verts[3], bm.verts[2], bm.verts[1], bm.verts[0]))
bm.faces.ensure_lookup_table()
uv_layer = bm.loops.layers.uv.verify()
face.loops[0][uv_layer].uv = (1.0, 1.0)
face.loops[1][uv_layer].uv = (1.0, -1.0)
face.loops[2][uv_layer].uv = (-1.0, -1.0)
face.loops[3][uv_layer].uv = (-1.0, 1.0)
bmesh.ops.contextual_create(bm, geom=bm.verts)
bm.to_mesh(mesh)
bm.free()
obj.location = context.scene.cursor.location
if do_select:
obj.select_set(True)
return obj
def post_process_material(self, context, files):
"""Run after the download of WM textures has completed."""
asset_name = self.asset_data.asset_name
asset_type_data = self.asset_data.get_type_data()
workflow = asset_type_data.get_workflow("METALNESS")
bpy.context.window.cursor_set("DEFAULT")
mat = cTB.mat_import.import_material(
asset_data=self.asset_data,
do_apply=False,
workflow=workflow,
size="WM",
size_bg=None,
lod="",
variant=None,
name_material=None,
name_mesh=None,
ref_objs=None,
projection="UV",
use_16bit=True,
mode_disp="NORMAL",
translate_x=0.0,
translate_y=0.0,
scale=1.0,
global_rotation=0.0,
aspect_ratio=1.0,
displacement=0.0,
keep_unused_tex_nodes=False,
reuse_existing=True,
map_prefs=None # Previews ignore map prefs
)
if mat is None:
self.report({"ERROR"}, _t("Material could not be created."))
reporting.capture_message(
"could_not_create_preview_mat", asset_name, "error")
return {"CANCELLED"}
objs_selected = [_obj
for _obj in context.scene.objects
if _obj.select_get()]
if len(objs_selected) == 0:
img_preview = None
for _img in bpy.data.images:
if _img.filepath in files:
img_preview = _img
# TODO(Andreas): Added this break as it seemed appropriate.
# Correct???
# Consequence is we take AR from first
# instead of last image in sequence
break
if img_preview is not None:
aspect_ratio = img_preview.size[0] / img_preview.size[1]
else:
aspect_ratio = 1.0
self.create_plane(
context, aspect_ratio=aspect_ratio, do_select=True)
result = bpy.ops.poliigon.poliigon_apply(
"INVOKE_DEFAULT",
asset_id=self.asset_id,
name_material=mat.name
)
if result == {"CANCELLED"}:
self.report(
{"WARNING"}, _t("Could not apply materials to selection"))
bpy.context.window.cursor_set("DEFAULT")
return {"FINISHED"}
def report_viewport(self, context):
"""Send the appropriate report based on the current shading mode."""
any_mat_or_render = False
for vA in context.screen.areas:
if vA.type != "VIEW_3D":
continue
for vSpace in vA.spaces:
if vSpace.type != "VIEW_3D":
continue
if vSpace.shading.type in ["MATERIAL", "RENDERED"]:
any_mat_or_render = True
if not any_mat_or_render:
msg = _t(
"Enter material or rendered mode to view applied quick preview"
)
self.report({'WARNING'}, msg)
class POLIIGON_OT_popup_first_preview(Operator):
bl_idname = "poliigon.popup_first_preview"
bl_label = _t("Texture Preview")
bl_description = _t("Download and apply a watermarked version of this texture for previewing")
bl_options = {"INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
force: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
def _load_images(self) -> None:
path = os.path.join(cTB.dir_script, "onboarding_watermarked.png")
self.img_welcome = load_image("POPUP_watermarked", path)
def invoke(self, context, event):
if cTB.settings["popup_preview"] and not self.force:
return {'FINISHED'}
self._load_images()
save_settings(cTB)
cTB.signal_popup(popup="ONBOARD_WMPREVIEW")
return context.window_manager.invoke_props_dialog(
self, width=POPUP_WIDTH_NARROW)
@reporting.handle_draw()
def draw(self, context):
label_width = POPUP_WIDTH_LABEL_NARROW * get_ui_scale(cTB)
# Accounting for the left+right border columns:
label_width -= 10.0
col_content = self.layout.column()
col_image = col_content.column()
col_image.scale_y = 0.5
col_image.template_icon(
icon_value=self.img_welcome.preview.icon_id,
scale=18.0)
row_text = col_content.row()
if bpy.app.version >= (3, 0):
col_left_gap = row_text.column()
col_left_gap.alignment = "LEFT"
col_left_gap.label(text=" ")
col_text = row_text.column()
else:
col_left_gap = row_text.column()
col_left_gap.alignment = "LEFT"
# Note, here no label in left column. Otherwise we'd end up with a
# way too larger border gap.
col_text = row_text.column()
col_text.alignment = "CENTER"
wrapped_label(
cTB,
width=label_width,
text=_t("Previews are watermarked 1K resolution textures."),
container=col_text,
add_padding=True)
wrapped_label(
cTB,
width=label_width,
text=_t("Subscribe to download high-resolution textures up to 8K "
"without watermarks."),
container=col_text,
add_padding_bottom=True)
@reporting.handle_operator(silent=True)
def execute(self, context):
cTB.settings["popup_preview"] = 1
save_settings(cTB)
cTB.signal_popup(popup="ONBOARD_WMPREVIEW", click="ONBOARD_WMPREVIEW")
bpy.ops.poliigon.poliigon_preview(asset_id=self.asset_id)
cTB.refresh_ui()
return {'FINISHED'}
@@ -0,0 +1,54 @@
# #### 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 bpy.props import StringProperty
from bpy.types import Operator
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
from .operator_detail_view import check_and_report_detail_view_not_opening
class POLIIGON_OT_refresh_data(Operator):
bl_idname = "poliigon.refresh_data"
bl_label = _t("Refresh data")
bl_description = _t("Refresh thumbnails and reload data")
bl_options = {"INTERNAL"}
tooltip: StringProperty(
options={"HIDDEN"}, # noqa: F821
default=_t("Refresh thumbnails and reload data")) # noqa: F722
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@reporting.handle_operator(silent=True)
def execute(self, context):
check_and_report_detail_view_not_opening()
cTB.refresh_data()
return {'FINISHED'}
@@ -0,0 +1,92 @@
# #### 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 bpy.types import Operator
from bpy.props import StringProperty
import bpy.utils.previews
from ..modules.poliigon_core.multilingual import _t
from ..dialogs.utils_dlg import (
get_ui_scale,
wrapped_label)
from ..toolbox import get_context
from .. import reporting
USER_COMMENT_LENGTH = 512 # Max length for user submitted error messages.
class POLIIGON_OT_report_error(Operator):
bl_idname = "poliigon.report_error"
bl_label = _t("Report error")
bl_description = _t("Report an error to the developers")
bl_options = {"INTERNAL"}
error_report: StringProperty(options={"HIDDEN"}) # noqa: F821
user_message: StringProperty(
default="", # noqa: F722
maxlen=USER_COMMENT_LENGTH,
options={'SKIP_SAVE'}) # noqa: F821
target_width = 600
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
def invoke(self, context, event):
width = self.target_width # Blender handles scaling to ui.
return context.window_manager.invoke_props_dialog(self, width=width)
@reporting.handle_draw()
def draw(self, context):
layout = self.layout
# Display the error message (no word wrapping in case too long)
box = layout.box()
box.scale_y = 0.5
box_wrap = self.target_width * get_ui_scale(cTB)
box_wrap -= 20 * get_ui_scale(cTB)
lines = self.error_report.split("\n")
if len(lines) > 10: # Prefer the last few lines.
lines = lines[-10:]
for ln in lines:
if not ln:
continue
box.label(text=ln)
# Display instructions to submit a comment.
label_txt = _t("(Optional) What were you doing when this error occurred?")
target_wrap = self.target_width * get_ui_scale(cTB)
target_wrap -= 10 * get_ui_scale(cTB)
wrapped_label(cTB, target_wrap, label_txt, layout)
layout.prop(self, "user_message", text="")
wrapped_label(cTB, target_wrap, _t("Press OK to send report"), layout)
@reporting.handle_operator(silent=True)
def execute(self, context):
if bpy.app.background: # No user to give feedback anyways.
return {'CANCELLED'}
reporting.user_report(self.error_report, self.user_message)
self.report({"INFO"}, _t("Thanks for sharing this report"))
return {'FINISHED'}
@@ -0,0 +1,68 @@
# #### 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 threading import Event
from bpy.types import Operator
from bpy.props import StringProperty
from ..modules.poliigon_core.api_remote_control import ApiJob
from ..preferences_map_prefs_util import update_map_prefs_properties
from ..toolbox import get_context
from ..toolbox_settings import save_settings
from .. import reporting
class POLIIGON_OT_reset_map_prefs(Operator):
bl_idname = "poliigon.reset_map_prefs"
bl_label = ""
bl_options = {"INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
def _callback_done(self, job: ApiJob) -> None:
self.event.set()
@reporting.handle_operator(silent=True)
def execute(self, context):
self.event = Event()
cTB.api_rc.add_job_get_download_prefs(
callback_cancel=None,
callback_progress=None,
callback_done=self._callback_done,
force=True)
self.event.wait(10.0)
update_map_prefs_properties(cTB)
save_settings(cTB)
return {'FINISHED'}
@@ -0,0 +1,170 @@
# #### 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 bpy.types import Operator
from bpy.props import StringProperty
import bpy.utils.previews
import bmesh
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_select(Operator):
bl_idname = "poliigon.poliigon_select"
bl_label = ""
bl_description = _t("Select Model")
bl_options = {"REGISTER", "INTERNAL", "UNDO"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
mode: StringProperty(options={"HIDDEN"}) # noqa: F821
data: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
def select_faces(self, context) -> None:
"""Selects all faces which have imported material assigned."""
self.deselect(context)
obj = context.active_object
idx_mat = int(self.data)
# #### vMat = obj.material_slots[i].material
bpy.ops.object.mode_set(mode="EDIT")
mesh = obj.data
bm = bmesh.from_edit_mesh(mesh)
for _face in bm.faces:
_face.select = 0
obj.active_material_index = idx_mat
bpy.ops.object.material_slot_select()
def select_object(self, context) -> None:
"""Selects an object by name."""
self.deselect(context)
obj = context.scene.objects[self.data]
try:
obj.select_set(True)
except RuntimeError:
pass # Might not be in view layer
def select_sets(self, context) -> None:
"""TODO(Andreas): Really not sure what this is.
What does it do? And for what purpose?
"""
parts_data = self.data.split("@")
self.deselect(context)
try:
context.scene.objects[parts_data[1]].select_set(1)
except RuntimeError:
pass # Might not be in view layer
def select_model(self, context) -> None:
"""Selects all objects belonging to an imported Model asset."""
if not context.mode == "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
self.deselect(context)
objs_to_select = []
key = self.data
for obj in context.scene.objects:
split = obj.name.rsplit("_")[0]
# For instance collections
if split == key and obj.instance_type == "COLLECTION":
objs_to_select.append(obj)
# For empty parents
ref_key = key + "_empty"
if obj.name.lower().startswith(ref_key.lower()):
objs_to_select.append(obj)
# For the objects within the empty tree
if key in obj.poliigon.split(";")[-1]:
objs_to_select.append(obj)
for _obj in objs_to_select:
try:
_obj.select_set(True)
except RuntimeError:
pass # Might not be in view layer
def select_objects_by_texture(self, context) -> None:
"""Selects all objects which have a given Texture asset assigned."""
mat = bpy.data.materials[self.data]
objs = [_obj
for _obj in context.scene.objects
if _obj.active_material == mat]
if len(objs) == 1:
self.deselect(context)
try:
objs[0].select_set(True)
except RuntimeError:
pass # Might not be in view layer
else:
# TODO(Andreas): Looks like this branch is never used.
# This operator seems to be only used in "model"
# mode.
# For sure the name access on our object list is a
# bug.
reporting.capture_message(
"reached_legacy_f_DropdownSelect", objs.name)
return {"FINISHED"}
@reporting.handle_operator()
def execute(self, context):
# TODO(Andreas): addon seems to use "mode == model", only
if self.mode == "faces":
self.select_faces(context)
elif self.mode == "object":
self.select_object(context)
# TODO(Andreas): Seems odd to have this one data comparison in between
# the mode ones...
elif "@" in self.data:
self.select_sets(context)
elif self.mode == "model":
self.select_model(context)
elif self.mode == "mat_objs":
self.select_objects_by_texture(context)
return {"FINISHED"}
def deselect(self, context):
"""Deselects objects in a lower api, faster, context-invaraint way."""
for obj in context.scene.collection.all_objects:
try:
obj.select_set(False)
except RuntimeError:
pass # Might not be in view layer
@@ -0,0 +1,447 @@
# #### 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 enum import IntEnum
from typing import Tuple
from bpy.types import Operator
import bpy.utils.previews
from ..modules.poliigon_core.api_remote_control_params import (
CATEGORY_FREE,
KEY_TAB_IMPORTED,
KEY_TAB_MY_ASSETS,
KEY_TAB_ONLINE)
from ..modules.poliigon_core.multilingual import _t
from ..asset_browser.asset_browser import create_poliigon_library
from ..dialogs.utils_dlg import get_ui_scale
from ..constants import HDRI_RESOLUTIONS
from ..toolbox import get_context
from ..toolbox_settings import save_settings
from ..utils import f_MDir
from .. import reporting
from .operator_detail_view import check_and_report_detail_view_not_opening
class ModeUpdate(IntEnum):
NO_UPDATE = 0
# Any update includes UI refresh
IMPORTED_AND_GET_ASSETS_AND_PAGE_1 = 1
IMPORTED_AND_GET_ASSETS = 2
IMPORTED_ONLY = 3
class POLIIGON_OT_setting(Operator):
bl_idname = "poliigon.poliigon_setting"
bl_label = ""
bl_description = _t("Edit Poliigon Addon Settings")
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"REGISTER", "INTERNAL"}
tooltip: bpy.props.StringProperty(default="", options={"HIDDEN"}) # noqa: F722, F821
mode: bpy.props.StringProperty(default="", options={"HIDDEN"}) # noqa: F722, F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@staticmethod
def _set_library() -> int:
path_library_new = cTB.settings["set_library"]
path_library_old = cTB.get_library_path(primary=True)
f_MDir(path_library_new)
cTB.replace_library_path(
path_library_old,
path_library_new,
primary=True,
update_local_assets=True)
if bpy.app.version >= (3, 0):
create_poliigon_library(force=True)
cTB.signal_popup(popup="ONBOARD_WELCOME")
bpy.ops.poliigon.popup_welcome("INVOKE_DEFAULT")
# do_update: update and switch to page 1
return ModeUpdate.IMPORTED_AND_GET_ASSETS_AND_PAGE_1
def _set_area(self) -> int:
with cTB.lock_thumbs:
cTB.thumbs.clear()
area = self.mode.replace("area_", "")
cTB.settings["area"] = area
# This caused a delay when switching between Poliigon/My Assets
# vClearCache = 1
cTB.settings["show_settings"] = 0
cTB.settings["show_user"] = 0
cTB.vActiveAsset = None
cTB.track_screen_from_area()
check_and_report_detail_view_not_opening()
# do_update: update, but skip switching to page 1 and getting assets
return ModeUpdate.IMPORTED_ONLY
@staticmethod
def _show_my_account() -> None:
cTB.settings["show_settings"] = 0
cTB.settings["show_user"] = 1
cTB.vActiveAsset = None
cTB.refresh_ui()
check_and_report_detail_view_not_opening()
@staticmethod
def _show_settings() -> None:
# TODO(Andreas): Does this code still have a purpose?
cTB.settings["show_settings"] = 1
cTB.settings["show_user"] = 0
cTB.vActiveAsset = None
cTB.refresh_ui()
def _set_category(self) -> int:
props = bpy.context.window_manager.poliigon_props
area = cTB.settings["area"]
mode_parts = self.mode.split("_")
idx_category = int(mode_parts[1])
button = mode_parts[2]
if idx_category < len(cTB.settings["category"][area]):
cTB.settings["category"][area][idx_category] = button
else:
cTB.settings["category"][area].append(button)
cTB.settings["category"][area] = cTB.settings[
"category"][area][: idx_category + 1]
categories = cTB.settings["category"][area]
if len(categories) > 1 and categories[-1].startswith("All "):
categories = categories[:-1]
cTB.settings["category"][area] = categories
cTB.vActiveAsset = None
cTB.vActiveMat = None
cTB.vActiveMode = None
# Do we want to clear searches when switching between areas?
cTB.vSearch[KEY_TAB_ONLINE] = props.search_poliigon
cTB.vSearch[KEY_TAB_MY_ASSETS] = props.search_my_assets
cTB.vSearch[KEY_TAB_IMPORTED] = props.search_imported
cTB.search_free = False
check_and_report_detail_view_not_opening()
# do_update: update and switch to page 1
return ModeUpdate.IMPORTED_AND_GET_ASSETS_AND_PAGE_1
def _set_free_search(self) -> int:
cTB.search_free = True
cTB.settings["category"][KEY_TAB_ONLINE] = [CATEGORY_FREE]
cTB.vActiveAsset = None
cTB.vActiveMat = None
cTB.vActiveMode = None
return ModeUpdate.IMPORTED_AND_GET_ASSETS_AND_PAGE_1
def _set_page(self) -> int:
with cTB.lock_thumbs:
cTB.thumbs.clear()
area = cTB.settings["area"]
idx_page = self.mode.split("_")[-1]
if cTB.vPage[area] == idx_page:
return {"FINISHED"}
elif idx_page == "-":
if cTB.vPage[area] > 0:
cTB.vPage[area] -= 1
elif idx_page == "+":
if cTB.vPage[area] < cTB.vPages[area]:
cTB.vPage[area] += 1
else:
cTB.vPage[area] = int(idx_page)
check_and_report_detail_view_not_opening()
# do_update: update, but skip switching to page 1 and getting assets
return ModeUpdate.IMPORTED_ONLY
def _set_page_size(self) -> Tuple[int, bool]:
per_page = int(self.mode.split("@")[1])
if cTB.settings["page"] == per_page:
return 0, False
cTB.settings["page"] = per_page
# do_update and clear_cache
# do_update: update and switch to page 1
return ModeUpdate.IMPORTED_AND_GET_ASSETS_AND_PAGE_1, True
def _clear_search(self) -> None:
props = bpy.context.window_manager.poliigon_props
if self.mode.endswith(KEY_TAB_ONLINE):
props.search_poliigon = ""
elif self.mode.endswith(KEY_TAB_MY_ASSETS):
props.search_my_assets = ""
elif self.mode.endswith(KEY_TAB_IMPORTED):
props.search_imported = ""
cTB.flush_thumb_prefetch_queue()
check_and_report_detail_view_not_opening()
@staticmethod
def _clear_email() -> None:
props = bpy.context.window_manager.poliigon_props
props.vEmail = ""
@staticmethod
def _clear_password() -> None:
props = bpy.context.window_manager.poliigon_props
props.vPassHide = ""
props.vPassShow = ""
@staticmethod
def _show_password() -> None:
# Can be removed if we're not going use the "show password" button
props = bpy.context.window_manager.poliigon_props
# TODO(Andreas): strange toggle logic?
if cTB.settings["show_pass"]:
props.vPassHide = (props.vPassShow)
else:
props.vPassShow = (props.vPassHide)
cTB.settings["show_pass"] = not cTB.settings["show_pass"]
def _set_thumb_size(self) -> None:
size = self.mode.split("@")[1]
if cTB.settings["thumbsize"] == size:
return
cTB.settings["thumbsize"] = size
cTB.refresh_ui()
def _set_mode(self) -> int:
cTB.settings[self.mode] = not cTB.settings[self.mode]
# Update the session reference of this setting too.
do_update = ModeUpdate.NO_UPDATE
if self.mode == "download_link_blend":
cTB.link_blend_session = cTB.settings[self.mode]
elif self.mode == "download_prefer_blend":
cTB._asset_index.flush_is_local()
cTB._asset_index.update_all_local_assets(
library_dirs=cTB.get_library_paths())
cTB.refresh_ui()
elif self.mode == "hdri_use_jpg_bg":
# do_update: update, but skip switching to page 1
do_update = ModeUpdate.IMPORTED_AND_GET_ASSETS
return do_update
def _set_default(self) -> int:
key = self.mode.split("_")[1]
value = self.mode.split("_")[2]
cTB.settings[key] = value
if not self.mode.startswith("default_hdri"):
cTB.refresh_ui()
return 0 # do_update: no update
idx_size_exr = HDRI_RESOLUTIONS.index(
cTB.settings["hdri"])
idx_size_jpg = HDRI_RESOLUTIONS.index(
cTB.settings["hdrib"])
if idx_size_jpg <= idx_size_exr:
idx_size_jpg_new = min(idx_size_exr + 1,
len(HDRI_RESOLUTIONS) - 1)
cTB.settings["hdrib"] = HDRI_RESOLUTIONS[idx_size_jpg_new]
# do_update: update, but skip switching to page 1
return ModeUpdate.IMPORTED_AND_GET_ASSETS
def _disable_library_directory(self) -> None:
directory = self.mode.replace("disable_dir_", "")
if directory in cTB.settings["disabled_dirs"]:
cTB.settings["disabled_dirs"].remove(directory)
cTB.logger.info(f"Enabled directory: {directory}")
cTB.add_library_path(
directory, primary=False, update_local_assets=True)
else:
cTB.settings["disabled_dirs"].append(directory)
cTB.logger.info(f"Disabled directory: {directory}")
cTB.remove_library_path(
directory, update_local_assets=True)
cTB.refresh_ui()
def _forget_library_directory(self) -> None:
directory = self.mode.replace("del_dir_", "")
cTB.remove_library_path(
directory, update_local_assets=True)
cTB.refresh_ui()
def _toggle_material_property(self) -> None:
prop = self.mode.split("@")[1]
if prop in cTB.settings["mat_props"]:
cTB.settings["mat_props"].remove(prop)
else:
cTB.settings["mat_props"].append(prop)
@staticmethod
def _view_more() -> int:
props = bpy.context.window_manager.poliigon_props
area = cTB.settings["area"]
prev_area = area
area = KEY_TAB_ONLINE
cTB.settings["area"] = area
cat_area = cTB.settings["category"][prev_area]
cTB.settings["category"][area] = cat_area
cTB.settings["show_settings"] = 0
cTB.settings["show_user"] = 0
cTB.vSearch[KEY_TAB_ONLINE] = cTB.vSearch[prev_area]
props.search_poliigon = cTB.vSearch[prev_area]
cTB.vActiveAsset = None
# do_update: update, but skip switching to page 1
return ModeUpdate.IMPORTED_AND_GET_ASSETS
@staticmethod
def _do_clear_cache(clear_cache: bool) -> None:
if not clear_cache:
return
# TODO(Andreas): I assume somebody planned some cTB.thumbs refresh
# or something?
@staticmethod
def _do_update(do_update: int) -> None:
if not do_update:
return
area = cTB.settings["area"]
cTB.flush_thumb_prefetch_queue()
if do_update == ModeUpdate.IMPORTED_AND_GET_ASSETS_AND_PAGE_1:
cTB.vPage[area] = 0
cTB.vPages[area] = 1
# Not setting cursor as it can lead to being stuck on "wait".
# bpy.context.window.cursor_set("WAIT")
# TODO(SOFT-762): refactor to cache raw API request, also validate
# if this needs re-requesting (has calls to f_GetCategoryChildren).
# TODO(Andreas): disabled, when going addon-core
# cTB.f_GetCategories()
# TODO(Andreas): redundant?
cTB.f_GetSceneAssets()
if do_update < ModeUpdate.IMPORTED_ONLY:
if area in [KEY_TAB_ONLINE, KEY_TAB_MY_ASSETS, KEY_TAB_IMPORTED]:
cTB.f_GetAssets(area=area)
cTB.refresh_ui()
@reporting.handle_operator()
def execute(self, context):
cTB.logger.debug(f"POLIIGON_OT_setting: mode='{self.mode}'")
get_ui_scale(cTB) # Force update DPI check for scale.
do_update = ModeUpdate.NO_UPDATE
clear_cache = False
if self.mode in ["none", ""]:
return {"FINISHED"}
elif self.mode == "set_library":
do_update = self._set_library()
elif self.mode.startswith("area_"):
do_update = self._set_area()
elif self.mode == "my_account":
self._show_my_account()
return {"FINISHED"}
elif self.mode == "settings":
self._show_settings()
return {"FINISHED"}
elif self.mode.startswith("category_"):
do_update = self._set_category()
elif self.mode.startswith("page_"):
do_update = self._set_page()
elif self.mode.startswith("page@"):
do_update, clear_cache = self._set_page_size()
elif self.mode.startswith("clear_search_"):
self._clear_search()
elif self.mode == "clear_email":
self._clear_email()
elif self.mode == "clear_pass":
self._clear_password()
elif self.mode == "show_pass":
self._show_password()
elif self.mode.startswith("thumbsize@"):
self._set_thumb_size()
elif self.mode in [
"apply_subdiv",
"auto_download",
"download_lods",
"download_prefer_blend",
"download_link_blend",
"hdri_use_jpg_bg",
"mat_props_edit",
"new_top",
"one_click_purchase",
"show_active",
"show_add_dir",
"show_asset_info",
"show_credits",
"show_default_prefs",
"show_display_prefs",
"show_import_prefs",
"show_asset_browser_prefs",
"show_mat_ops",
"show_mat_props",
"show_mat_texs",
"show_plan",
"show_feedback",
"show_settings",
"show_user",
"use_16"
]:
do_update = self._set_mode()
elif self.mode.startswith("default_"):
do_update = self._set_default()
elif self.mode.startswith("disable_dir_"):
self._disable_library_directory()
elif self.mode.startswith("del_dir_"):
self._forget_library_directory()
elif self.mode.startswith("prop@"):
self._toggle_material_property()
elif self.mode == "view_more":
do_update = self._view_more()
elif self.mode == "search_free":
do_update = self._set_free_search()
else:
reporting.capture_message("invalid_setting_mode", self.mode)
self.report(
{"WARNING"}, _t("Invalid setting mode {0}").format(self.mode))
return {'CANCELLED'}
self._do_clear_cache(clear_cache)
self._do_update(do_update)
save_settings(cTB)
return {"FINISHED"}
@@ -0,0 +1,124 @@
# #### 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 addon_utils
import bpy
from bpy.types import Operator
from bpy.props import EnumProperty
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import (
get_context,
get_prefs)
from .. import reporting
from .operator_detail_view import check_and_report_detail_view_not_opening
class POLIIGON_OT_show_preferences(Operator):
"""Open user preferences and display Poliigon settings"""
bl_idname = "poliigon.open_preferences"
bl_label = _t("Show Poliigon preferences")
_options = (
("skip",
_t("Skip"),
_t("Open user preferences as-is without changing visible areas")),
("all",
_t("All"),
_t("Expand all sections of user preferences")),
("show_add_dir",
_t("Additional library"),
_t("Show additional library directory preferences")),
("show_display_prefs",
_t("Display"),
_t("Show display preferences")),
("show_default_prefs",
_t("Asset prefs"),
_t("Show asset preferences"))
)
set_focus: EnumProperty(items=_options, options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@reporting.handle_operator()
def execute(self, context):
prefs = get_prefs()
if self.set_focus == "all":
cTB.settings["show_add_dir"] = True
cTB.settings["show_display_prefs"] = True
cTB.settings["show_default_prefs"] = True
cTB.settings["show_updater_prefs"] = True
if prefs:
prefs.show_updater_prefs = True
elif self.set_focus != "skip":
show_add_dir = self.set_focus == "show_add_dir"
cTB.settings["show_add_dir"] = show_add_dir
show_display_prefs = self.set_focus == "show_display_prefs"
cTB.settings["show_display_prefs"] = show_display_prefs
show_default_prefs = self.set_focus == "show_default_prefs"
cTB.settings["show_default_prefs"] = show_default_prefs
if prefs:
prefs.show_updater_prefs = False
bpy.ops.screen.userpref_show('INVOKE_AREA')
bpy.data.window_managers["WinMan"].addon_search = "Poliigon"
prefs = context.preferences
try:
prefs.active_section = "ADDONS"
except TypeError as err:
reporting.capture_message(
"assign_preferences_tab", str(err), "error")
# __spec__.parent since __package__ got deprecated
# Since this module moved into operators,
# we need to split off .operators
spec_parent = __spec__.parent
spec_parent = spec_parent.split(".")[0]
addons_ids = [
mod for mod in addon_utils.modules(refresh=False)
if mod.__name__ == spec_parent]
if not addons_ids:
msg = "Failed to directly load and open Poliigon preferences"
reporting.capture_message(
"preferences_open_no_id", msg, "error")
return {'CANCELLED'}
addon_blinfo = addon_utils.module_bl_info(addons_ids[0])
if not addon_blinfo["show_expanded"]:
has_prefs = hasattr(bpy.ops, "preferences")
has_prefs = has_prefs and hasattr(bpy.ops.preferences,
"addon_expand")
if has_prefs: # later 2.8 buids
bpy.ops.preferences.addon_expand(module=spec_parent)
else:
self.report(
{"INFO"},
_t("Search for and expand the Poliigon addon in preferences")
)
check_and_report_detail_view_not_opening()
cTB.track_screen("settings")
return {'FINISHED'}
@@ -0,0 +1,63 @@
# #### 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
from bpy.types import Operator
from bpy.props import (BoolProperty,
IntProperty,
StringProperty)
import bpy.utils.previews
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from ..dialogs.dlg_quickmenu import show_quick_menu
from .. import reporting
from .operator_detail_view import check_and_report_detail_view_not_opening
class POLIIGON_OT_show_quick_menu(Operator):
bl_idname = "poliigon.show_quick_menu"
bl_label = ""
bl_description = _t("Show quick menu")
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
hide_detail_view: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@reporting.handle_operator()
def execute(self, context):
if bpy.app.background:
return {'CANCELLED'} # Don't popup menus when running headless.
check_and_report_detail_view_not_opening()
asset_data = cTB._asset_index.get_asset(self.asset_id)
show_quick_menu(
cTB, asset_data=asset_data, hide_detail_view=self.hide_detail_view)
return {'FINISHED'}
@@ -0,0 +1,64 @@
# #### 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 bpy.props import StringProperty
from bpy.types import Operator
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from ..dialogs.dlg_popup import open_popup
from .. import reporting
class POLIIGON_OT_unsupported_convention(Operator):
bl_idname = "poliigon.unsupported_convention"
bl_label = _t("Unsupported")
bl_description = _t(
"Addon does not support asset convention, try updating the addon")
bl_options = {'REGISTER', 'INTERNAL'}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@reporting.handle_operator()
def execute(self, context):
# TODO(Andreas): Does this work for translation?
msg = _t(
"Asset not supported, please update plugin. "
"This asset is published with newer conventions to improve "
"render outputs."
)
open_popup(
cTB,
title=_t("Addon Update Needed"),
msg=msg,
buttons=[_t("OK"), _t("Update")],
commands=["check_update", "open_p4b_url"],
mode=None,
w_limit=210)
return {"FINISHED"}
@@ -0,0 +1,172 @@
# #### 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 datetime
from threading import Event
import time
from bpy.types import Operator
from bpy.props import (BoolProperty, StringProperty)
import bpy
from ..modules.poliigon_core.api_remote_control import ApiJob
from ..modules.poliigon_core.api_remote_control_params import CmdLoginMode
from ..dialogs.dlg_login import ERR_CREDS_FORMAT
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_user(Operator):
bl_idname = "poliigon.poliigon_user"
bl_label = ""
bl_description = ""
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"REGISTER", "INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
mode: StringProperty(options={"HIDDEN"}) # noqa: F821
do_synchronous: BoolProperty(options={"HIDDEN"}, default=False) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
def _login_determine_elapsed(self) -> None:
"""Calculates the time between addon enable and login.
This is included in the initiate login or direct email/pwd login only
if this is the first time install+login. This value gets included in
the initiate/login request which will treat as an addon install event.
"""
cTB.login_elapsed_s = None
if not cTB.settings["first_enabled_time"]:
return
now = datetime.datetime.now()
install_tstr = cTB.settings["first_enabled_time"]
install_t = datetime.datetime.strptime(
install_tstr, "%Y-%m-%d %H:%M:%S")
elapsed = now - install_t
cTB.login_elapsed_s = int(elapsed.total_seconds())
if cTB.login_elapsed_s <= 0:
cTB.logger.debug(
"POLIIGON_OT_user Throwing out negative elapsed time")
cTB.login_elapsed_s = None
def _callback_login_done_sync(self, job: ApiJob) -> None:
cTB.callback_login_done(job)
self.event_sync.set()
def _callback_logout_done_sync(self, job: ApiJob) -> None:
cTB.callback_logout_done(job)
self.event_sync.set()
def _do_login(self, cTB) -> None:
cTB.logger.debug(
"POLIIGON_OT_user Sending login with website request")
cTB.login_cancelled = False
cTB.login_res = None
cTB.login_time_start = time.time()
cTB.login_in_progress = True
cTB.last_login_error = None
if self.do_synchronous:
self.event_sync = Event()
callback_login_done = self._callback_login_done_sync
callback_logout_done = self._callback_logout_done_sync
else:
callback_login_done = cTB.callback_login_done
callback_logout_done = cTB.callback_logout_done
if self.mode == "login_with_website":
mode = CmdLoginMode.LOGIN_BROWSER
callback_cancel = cTB.callback_login_cancel
callback_done = callback_login_done
email = None
pwd = None
login_elapsed_s = cTB.login_elapsed_s
elif self.mode == "login":
mode = CmdLoginMode.LOGIN_CREDENTIALS
callback_cancel = None
callback_done = callback_login_done
email = bpy.context.window_manager.poliigon_props.vEmail
pwd = bpy.context.window_manager.poliigon_props.vPassHide
login_elapsed_s = cTB.login_elapsed_s
elif self.mode == "logout":
bpy.ops.poliigon.poliigon_setting(mode="clear_email")
bpy.ops.poliigon.poliigon_setting(mode="clear_pass")
mode = CmdLoginMode.LOGOUT
callback_cancel = None
callback_done = callback_logout_done
email = None
pwd = None
login_elapsed_s = None
cTB.api_rc.add_job_login(
mode=mode,
email=email,
pwd=pwd,
time_since_enable=login_elapsed_s,
callback_cancel=callback_cancel,
callback_done=callback_done,
force=True
)
if self.do_synchronous:
self.event_sync.wait(30.0)
@reporting.handle_operator()
def execute(self, context):
global cTB
props = bpy.context.window_manager.poliigon_props
if self.mode == "login":
if "@" not in props.vEmail or len(props.vPassHide) < 6:
cTB.clear_user_invalidated()
cTB.last_login_error = ERR_CREDS_FORMAT
return {"CANCELLED"}
self._login_determine_elapsed()
if self.mode in ["login", "login_with_website", "logout"]:
self._do_login(cTB)
elif self.mode == "login_cancel":
cTB.login_cancelled = True
elif self.mode == "login_switch_to_email":
cTB.last_login_error = None
cTB.login_mode_browser = False
elif self.mode == "login_switch_to_browser":
cTB.login_mode_browser = True
else:
cTB.logger.error(
f"POLIIGON_OT_user UNKNOWN LOGIN COMMAND {self.mode}")
cTB.refresh_ui()
return {"FINISHED"}
@@ -0,0 +1,253 @@
# #### 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
from typing import Tuple
import bpy
from bpy.types import Operator
from bpy.props import (
IntProperty,
StringProperty)
import bpy.utils.previews
from ..modules.poliigon_core.assets import AssetData
from ..modules.poliigon_core.multilingual import _t
from ..toolbox import get_context
from .. import reporting
class POLIIGON_OT_view_thumbnail(Operator):
bl_idname = "poliigon.view_thumbnail"
bl_label = ""
bl_description = _t("View larger thumbnail")
bl_options = {"INTERNAL"}
tooltip: StringProperty(options={"HIDDEN"}) # noqa: F821
asset_id: IntProperty(options={"HIDDEN"}) # noqa: F821
thumbnail_index: IntProperty(min=0, options={"HIDDEN"}) # noqa: F821
resolution: IntProperty(min=300, default=900, options={"HIDDEN"}) # noqa: F821
@staticmethod
def init_context(addon_version: str) -> None:
"""Called from operators.py to init global addon context."""
global cTB
cTB = get_context(addon_version)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@reporting.handle_operator()
def execute(self, context):
asset_data = cTB._asset_index.get_asset(self.asset_id)
op_dims_backup = self._backup_show_render_op_dimensions(context)
# Don't modify by get_ui_scale(), as it uses physical pixel size. Also
# making it smaller than 1024 to minimize margins around image.
pixels = int(self.resolution + 100)
pixels = min(pixels, 1000)
res = {'CANCELLED'}
try:
# Modify render settings to force new window to appear.
self._set_show_render_op_dimensions(context, pixels, pixels)
# Main loading steps
area = self._create_window(asset_data)
download_ok = self._download_preview(asset_data)
if download_ok:
res = self._load_preview(area, asset_data)
except Exception as e:
# If exception occurs, will run after the finally block below.
raise e
finally:
# Ensure we always restore render settings and preferences.
self._restore_show_render_op_dimensions(context, op_dims_backup)
cTB.track_screen("large_preview")
return res
def _backup_show_render_op_dimensions(
self, context) -> Tuple[int, int, str]:
"""Stores/backups dimensions of 'show render' operator."""
# We use the show render operator to hack setting an explicit size,
# be sure to capture the resolution to revert back.
render = bpy.context.scene.render
init_res_x = render.resolution_x
init_res_y = render.resolution_y
if hasattr(context.preferences.view, "render_display_type"):
init_display = bpy.context.preferences.view.render_display_type
else:
init_display = context.scene.render.display_mode
return (init_res_x, init_res_y, init_display)
def _restore_show_render_op_dimensions(
self, context, dims_backup: Tuple[int, int, str]) -> None:
"""Restores dimensions of 'show render' operator."""
init_res_x, init_res_y, init_display = dims_backup
render = bpy.context.scene.render
render.resolution_x = init_res_x
render.resolution_y = init_res_y
if hasattr(context.preferences.view, "render_display_type"):
context.preferences.view.render_display_type = init_display
else:
context.scene.render.display_mode = init_display
def _set_show_render_op_dimensions(
self, context, width: int, height: int) -> None:
"""Sets dimensions of 'show render' operator."""
render = bpy.context.scene.render
render.resolution_x = width
render.resolution_y = height
if hasattr(context.preferences.view, "render_display_type"):
context.preferences.view.render_display_type = "WINDOW"
else:
context.scene.render.display_mode = "WINDOW"
def _get_thumb_path_and_url(self) -> Tuple[str, str]:
path_thumb, url_thumb = cTB._asset_index.get_cf_thumbnail_info(
self.asset_id,
resolution=self.resolution,
index=self.thumbnail_index
)
if path_thumb is None or url_thumb is None:
# Issue already reported inside get_cf_thumbnail_info()
return None, None
path, ext = os.path.splitext(path_thumb)
path_thumb = f"{path}_{self.resolution}px{ext}"
return path_thumb, url_thumb
@staticmethod
def _delete_temp_file(path_thumb_dl: str) -> None:
"""Delete temp file from previous download attempts."""
if not os.path.exists(path_thumb_dl):
return
try:
os.remove(path_thumb_dl)
except Exception:
# TODO(Andreas): Not sure, what to do now.
# Would we want to report this?
pass
@staticmethod
def _rename_successful_download(
path_thumb_dl: str, path_thumb: str) -> None:
if not os.path.isfile(path_thumb_dl):
return
try:
os.rename(path_thumb_dl, path_thumb)
except Exception:
POLIIGON_OT_view_thumbnail._delete_temp_file(path_thumb_dl)
# TODO(Andreas): Not sure, we would want to report this?
def _download_preview_exec(self, asset_data: AssetData) -> bool:
path_thumb, url_thumb = self._get_thumb_path_and_url()
if path_thumb is None or url_thumb is None:
return False
if os.path.isfile(path_thumb):
return True # already local
asset_name = asset_data.asset_name
path_thumb_dl = f"{path_thumb}_dl"
self._delete_temp_file(path_thumb_dl)
resp = cTB._api.download_preview(url_thumb, path_thumb_dl, asset_name)
if not resp.ok:
# Issue already reported inside download_preview()
self._delete_temp_file(path_thumb_dl)
return False # Failed download
self._rename_successful_download(path_thumb_dl, path_thumb)
return True
def _download_preview(
self, asset_data: AssetData) -> bool:
"""Download the target thumbnail if not local, no threading."""
cTB.logger.debug("POLIIGON_OT_view_thumbnail Download thumbnail index "
f"{self.thumbnail_index}")
bpy.context.window.cursor_set("WAIT")
result = self._download_preview_exec(asset_data)
bpy.context.window.cursor_set("DEFAULT")
return result
def _create_window(self, asset_data: AssetData):
# Call image editor window
asset_name = asset_data.asset_name
bpy.ops.render.view_show("INVOKE_DEFAULT")
# Set up the window as needed.
area = None
for _window in bpy.context.window_manager.windows:
this_area = _window.screen.areas[0]
if this_area.type == "IMAGE_EDITOR":
area = this_area
break
if not area:
return None
dispaly_details = asset_data.get_display_details_data()
details = dispaly_details.get("Physical Size", "")
rwsize = f" ({details})" if details else ""
area.header_text_set(_t("Asset thumbnail: {0} {1}").format(
asset_name, rwsize))
area.show_menus = False
return area
def _load_preview(self, area, asset_data: AssetData):
"""Load in the image preview based on the area."""
asset_name = asset_data.asset_name
path_thumb, _ = self._get_thumb_path_and_url()
if not os.path.isfile(path_thumb):
self.report({"ERROR"}, _t("Could not find image preview"))
msg = f"{asset_name}: Could not find image preview {path_thumb}"
reporting.capture_message("thumbnail_file_missing", msg, "error")
return {'CANCELLED'}
thumbnail = bpy.data.images.load(path_thumb)
if area:
area.spaces[0].image = thumbnail
else:
msg = _t("Open the image now loaded in an image viewer")
self.report({"ERROR"}, msg)
err = "Failed to open window for preview"
reporting.capture_message("img_window_failed_open", err, "info")
# Tag this image with a property, could be used to trigger UI draws in
# the viewer in the future.
thumbnail["poliigon_thumbnail"] = True
return {'FINISHED'}
@@ -0,0 +1,172 @@
# #### 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.utils.previews
from .operator_active import POLIIGON_OT_active
from .operator_add_converter_node import POLIIGON_OT_add_converter_node
from .operator_apply import POLIIGON_OT_apply
from .operator_cancel_download import POLIIGON_OT_cancel_download
from .operator_category import POLIIGON_OT_category
from .operator_check_update import POLIIGON_OT_check_update
from .operator_close_notification import POLIIGON_OT_close_notification
from .operator_detail import POLIIGON_OT_detail
from .operator_detail_view import (
POLIIGON_OT_detail_view,
POLIIGON_OT_detail_view_open,
POLIIGON_OT_detail_view_select)
from .operator_directory import POLIIGON_OT_directory
from .operator_download import (
POLIIGON_OT_download,
POLIIGON_OT_popup_purchase)
from .operator_folder import POLIIGON_OT_folder
from .operator_hdri import POLIIGON_OT_hdri
from .operator_library import POLIIGON_OT_library
from .operator_link import POLIIGON_OT_link
from .operator_local_asset_sync import POLIIGON_OT_get_local_asset_sync
from .operator_load_asset_from_list import (
POLIIGON_OT_load_asset_size_from_list)
from .operator_material import POLIIGON_OT_material
from .operator_model import POLIIGON_OT_model
from .operator_options import POLIIGON_OT_options
from .operator_notice import POLIIGON_OT_notice_operator
from .operator_popup_download_limit import POLIIGON_OT_popup_download_limit
from .operator_popup_message import POLIIGON_OT_popup_message
from .operator_popup_change_plan import (
POLIIGON_OT_banner_change_plan_dismiss,
POLIIGON_OT_banner_finish_dismiss,
POLIIGON_OT_change_plan,
POLIIGON_OT_popup_change_plan)
from .operator_popups_onboarding import (
POLIIGON_OT_popup_welcome,
POLIIGON_OT_popup_first_download)
from .operator_preview import (
POLIIGON_OT_popup_first_preview,
POLIIGON_OT_preview)
from .operator_refresh_data import POLIIGON_OT_refresh_data
from .operator_report_error import POLIIGON_OT_report_error
from .operator_reset_map_prefs import POLIIGON_OT_reset_map_prefs
from .operator_show_preferences import POLIIGON_OT_show_preferences
from .operator_select import POLIIGON_OT_select
from .operator_setting import POLIIGON_OT_setting
from .operator_show_quick_menu import POLIIGON_OT_show_quick_menu
try:
from .operator_unit_test_helper import (
POLIIGON_OT_unit_test_helper,
UnitTestProperties)
HAVE_UNIT_TEST_HELPER = True
except ImportError:
# It is fine, to not have this. We only use it in uit tests.
HAVE_UNIT_TEST_HELPER = False
from .operator_unsupported_convention import POLIIGON_OT_unsupported_convention
from .operator_user import POLIIGON_OT_user
from .operator_view_thumbnail import POLIIGON_OT_view_thumbnail
from ..toolbox import get_context
classes = (
POLIIGON_OT_active,
POLIIGON_OT_add_converter_node,
POLIIGON_OT_apply,
POLIIGON_OT_banner_change_plan_dismiss,
POLIIGON_OT_banner_finish_dismiss,
POLIIGON_OT_cancel_download,
POLIIGON_OT_category,
POLIIGON_OT_change_plan,
POLIIGON_OT_check_update,
POLIIGON_OT_close_notification,
POLIIGON_OT_detail,
POLIIGON_OT_detail_view,
POLIIGON_OT_detail_view_open,
POLIIGON_OT_detail_view_select,
POLIIGON_OT_directory,
POLIIGON_OT_download,
POLIIGON_OT_folder,
POLIIGON_OT_get_local_asset_sync,
POLIIGON_OT_hdri,
POLIIGON_OT_library,
POLIIGON_OT_link,
POLIIGON_OT_load_asset_size_from_list,
POLIIGON_OT_material,
POLIIGON_OT_model,
POLIIGON_OT_notice_operator,
POLIIGON_OT_options,
POLIIGON_OT_popup_change_plan,
POLIIGON_OT_popup_download_limit,
POLIIGON_OT_popup_first_download,
POLIIGON_OT_popup_first_preview,
POLIIGON_OT_popup_message,
POLIIGON_OT_popup_purchase,
POLIIGON_OT_popup_welcome,
POLIIGON_OT_preview,
POLIIGON_OT_refresh_data,
POLIIGON_OT_report_error,
POLIIGON_OT_reset_map_prefs,
POLIIGON_OT_select,
POLIIGON_OT_setting,
POLIIGON_OT_show_preferences,
POLIIGON_OT_show_quick_menu,
POLIIGON_OT_unsupported_convention,
POLIIGON_OT_user,
POLIIGON_OT_view_thumbnail
)
cTB = None
def register_unit_test_helper() -> None:
global classes
if not HAVE_UNIT_TEST_HELPER:
return
if POLIIGON_OT_unit_test_helper not in classes:
# Have operator un-/registered like all others
classes = (*classes, POLIIGON_OT_unit_test_helper)
bpy.utils.register_class(UnitTestProperties)
bpy.types.WindowManager.polligon_unit_test = bpy.props.PointerProperty(
type=UnitTestProperties)
def unregister_unit_test_helper() -> None:
if not HAVE_UNIT_TEST_HELPER:
return
del bpy.types.WindowManager.polligon_unit_test
bpy.utils.unregister_class(UnitTestProperties)
def register(addon_version: str) -> None:
global cTB
register_unit_test_helper()
cTB = get_context(addon_version)
for cls in classes:
bpy.utils.register_class(cls)
cls.init_context(addon_version)
def unregister() -> None:
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
unregister_unit_test_helper()
@@ -0,0 +1,37 @@
# #### 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 typing import List, Tuple
def fill_size_drop_down(addon, asset_id: int) -> List[Tuple[str, str, str]]:
"""Returns a list of enum items with locally available sizes."""
asset_data = addon._asset_index.get_asset(asset_id)
asset_type_data = asset_data.get_type_data()
local_sizes = asset_type_data.get_size_list(
local_only=True,
addon_convention=addon._asset_index.addon_convention,
local_convention=asset_data.get_convention(local=True))
# Populate dropdown items
items_size = []
for size in local_sizes:
# Tuple: (id, name, description, icon, enum value)
items_size.append((size, size, size))
return items_size