diff options
38 files changed, 12449 insertions, 0 deletions
diff --git a/blenderkit/README.md b/blenderkit/README.md new file mode 100644 index 00000000..e2e77067 --- /dev/null +++ b/blenderkit/README.md @@ -0,0 +1,3 @@ +BlenderKit add-on is the official addon of the BlenderKit service for Blender 3d. +It enables users to upload, search, download, and rate different assets for blender. +It works together with BlenderKit server.
\ No newline at end of file diff --git a/blenderkit/__init__.py b/blenderkit/__init__.py new file mode 100644 index 00000000..017da14d --- /dev/null +++ b/blenderkit/__init__.py @@ -0,0 +1,1410 @@ +# ##### 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 ##### + +bl_info = { + "name": "Blender Kit", + "author": "Vilem Duha", + "version": (1, 0, 22), + "blender": (2, 80, 0), + "location": "View3D > Properties > BlenderKit", + "description": "Online BlenderKit library (materials, models, brushes and more)", + "warning": "", + "wiki_url": "https://youtu.be/1hVgcQhIAo8" + "Scripts/Add_Mesh/BlenderKit", + "category": "Add Mesh", +} + +if "bpy" in locals(): + import importlib + + importlib.reload(asset_inspector) + importlib.reload(search) + importlib.reload(download) + importlib.reload(ratings) + importlib.reload(autothumb) + importlib.reload(ui) + importlib.reload(bg_blender) + importlib.reload(paths) + importlib.reload(utils) +else: + from blenderkit import asset_inspector, search, download, upload, ratings, autothumb, ui, bg_blender, paths, utils, \ + overrides, ui_panels, categories + +import os +import math +import time +# import logging +import bpy + +from bpy.app.handlers import persistent +import bpy.utils.previews +import mathutils +from mathutils import Vector +from bpy.props import ( + IntProperty, + FloatProperty, + FloatVectorProperty, + StringProperty, + EnumProperty, + BoolProperty, + PointerProperty, +) +from bpy.types import ( + Operator, + Panel, + AddonPreferences, + PropertyGroup, +) + + +# logging.basicConfig(filename = 'blenderkit.log', level = logging.INFO, +# format = ' %(asctime)s:%(filename)s:%(funcName)s:%(lineno)d:%(message)s') + + +@persistent +def scene_load(context): + search.load_previews() + ui_props = bpy.context.scene.blenderkitUI + ui_props.assetbar_on = False + ui_props.turn_off = False + + +licenses = ( + ('royalty_free', 'Royalty Free', 'royalty free commercial license'), + ('cc_zero', 'Creative Commons Zero', 'Creative Commons Zero'), +) +conditions = ( + ('UNSPECIFIED', 'Unspecified', "Don't use this in search"), + ('NEW', 'New', 'Shiny new item'), + ('USED', 'Used', 'Casually used item'), + ('OLD', 'Old', 'Old item'), + ('DESOLATE', 'Desolate', 'Desolate item - dusty & rusty'), +) +model_styles = ( + ('REALISTIC', 'Realistic', "photo realistic model"), + ('PAINTERLY', 'Painterly', 'hand painted with visible strokes, mostly for games'), + ('LOWPOLY', 'Lowpoly', "Lowpoly art -don't mix up with polycount!"), + ('ANIME', 'Anime', 'Anime style'), + ('2D VECTOR', '2d Vector', '2d vector'), + ('3D GRAPHICS', '3d Graphics', '3d graphics'), + ('OTHER', 'Other', 'Other style'), +) +search_model_styles = ( + ('REALISTIC', 'Realistic', "photo realistic model"), + ('PAINTERLY', 'Painterly', 'hand painted with visible strokes, mostly for games'), + ('LOWPOLY', 'Lowpoly', "Lowpoly art -don't mix up with polycount!"), + ('ANIME', 'Anime', 'Anime style'), + ('2D VECTOR', '2d Vector', '2d vector'), + ('3D GRAPHICS', '3d Graphics', '3d graphics'), + ('OTHER', 'Other', 'Other Style'), + ('ANY', 'Any', 'Any Style'), +) +material_styles = ( + ('REALISTIC', 'Realistic', "photo realistic model"), + ('NPR', 'Non photorealistic', 'hand painted with visible strokes, mostly for games'), + ('OTHER', 'Other', 'Other style'), +) +search_material_styles = ( + ('REALISTIC', 'Realistic', "photo realistic model"), + ('NPR', 'Non photorealistic', 'hand painted with visible strokes, mostly for games'), + ('ANY', 'Any', 'Any'), +) +engines = ( + ('CYCLES', 'Cycles', 'blender cycles pathtracer'), + ('EEVEE', 'Eevee', 'blender eevee renderer'), + ('OCTANE', 'Octane', 'octane render enginge'), + ('ARNOLD', 'Arnold', 'arnold render engine'), + ('V-RAY', 'V-Ray', 'V-Ray renderer'), + ('UNREAL', 'Unreal', 'Unreal engine'), + ('UNITY', 'Unity', 'Unity engine'), + ('GODOT', 'Godot', 'Godot engine'), + ('3D-PRINT', '3d printer', 'object can be 3d printed'), + ('OTHER', 'Other', 'any other engine'), + ('NONE', 'None', 'no more engine block.'), +) +pbr_types = ( + ('METALLIC', 'Metallic-Roughness', 'Metallic/Roughness PBR material type'), + ('SPECULAR', 'Specular Glossy', ''), +) + +mesh_poly_types = ( + ('QUAD', 'quad', ''), + ('QUAD_DOMINANT', 'quad_dominant', ''), + ('TRI_DOMINANT', 'tri_dominant', ''), + ('TRI', 'tri', ''), + ('NGON', 'ngon_dominant', ''), + ('OTHER', 'other', ''), +) + +thumbnail_angles = ( + ('DEFAULT', 'default', ''), + ('FRONT', 'front', ''), + ('SIDE', 'side', ''), + ('TOP', 'top', ''), +) + +thumbnail_snap = ( + ('GROUND', 'ground', ''), + ('WALL', 'wall', ''), + ('CEILING', 'ceiling', ''), + ('FLOAT', 'floating', ''), +) + + +def get_upload_asset_type(self): + typemapper = { + BlenderKitModelUploadProps: 'model', + BlenderKitSceneUploadProps: 'scene', + BlenderKitMaterialUploadProps: 'material', + BlenderKitBrushUploadProps: 'brush' + } + asset_type = typemapper[type(self)] + return asset_type + + +def get_subcategory_enums(self, context): + wm = bpy.context.window_manager + asset_type = get_upload_asset_type(self) + items = [] + if self.category != '': + asset_categories = categories.get_category(wm['bkit_categories'], cat_path=(asset_type, self.category,)) + for c in asset_categories['children']: + items.append((c['slug'], c['name'], c['description'])) + + return items + + +def get_category_enums(self, context): + wm = bpy.context.window_manager + asset_type = get_upload_asset_type(self) + asset_categories = categories.get_category(wm['bkit_categories'], cat_path=(asset_type,)) + items = [] + for c in asset_categories['children']: + items.append((c['slug'], c['name'], c['description'])) + return items + + +def switch_search_results(self, context): + s = bpy.context.scene + props = s.blenderkitUI + if props.asset_type == 'MODEL': + s['search results'] = s.get('bkit model search') + elif props.asset_type == 'SCENE': + s['search results'] = s.get('bkit scene search') + elif props.asset_type == 'MATERIAL': + s['search results'] = s.get('bkit material search') + elif props.asset_type == 'TEXTURE': + s['search results'] = s.get('bkit texture search') + elif props.asset_type == 'BRUSH': + s['search results'] = s.get('bkit brush search') + search.load_previews() + + +class BlenderKitUIProps(PropertyGroup): + down_up: EnumProperty( + name="Download vs Upload", + items=( + ('SEARCH', 'Search', 'Searching is active', 'VIEWZOOM', 0), + ('UPLOAD', 'Upload', 'Uploads are active', 'COPYDOWN', 1), + # ('RATING', 'Rating', 'Rating is active', 'SOLO_ON', 2) + ), + description="BLenderKit", + default="SEARCH", + ) + asset_type: EnumProperty( + name="Active Asset Type", + items=( + ('MODEL', 'Model', 'Browse models', 'OBJECT_DATAMODE', 0), + ('SCENE', 'SCENE', 'Browse scenes', 'SCENE_DATA', 1), + ('MATERIAL', 'Material', 'Browse models', 'MATERIAL', 2), + # ('TEXTURE', 'Texture', 'Browse textures', 'TEXTURE', 3), + ('BRUSH', 'Brush', 'Browse brushes', 'BRUSH_DATA', 3) + ), + description="Activate asset in UI", + default="MATERIAL", + update=switch_search_results + ) + # these aren't actually used ( by now, seems to better use globals in UI module: + draw_tooltip: BoolProperty(name="Draw Tooltip", default=False) + addon_update: BoolProperty(name="Should Update Addon", default=False) + tooltip: StringProperty( + name="Tooltip", + description="asset preview info", + default="") + + ui_scale = 1 + + thumb_size_def = 96 + margin_def = 0 + + thumb_size: IntProperty(name="Thumbnail Size", default=thumb_size_def, min=-1, max=256) + + margin: IntProperty(name="Margin", default=margin_def, min=-1, max=256) + highlight_margin: IntProperty(name="Higlight Margin", default=int(margin_def / 2), min=-10, max=256) + + bar_height: IntProperty(name="Bar Height", default=thumb_size_def + 2 * margin_def, min=-1, max=2048) + bar_x_offset: IntProperty(name="Bar X Offset", default=20, min=0, max=5000) + bar_y_offset: IntProperty(name="Bar Y Offset", default=80, min=0, max=5000) + + bar_x: IntProperty(name="Bar X", default=100, min=0, max=5000) + bar_y: IntProperty(name="Bar Y", default=100, min=50, max=5000) + bar_end: IntProperty(name="Bar End", default=100, min=0, max=5000) + bar_width: IntProperty(name="Bar Width", default=100, min=0, max=5000) + + wcount: IntProperty(name="Width Count", default=10, min=0, max=5000) + hcount: IntProperty(name="Rows", default=5, min=0, max=5000) + + reports_y: IntProperty(name="Reports Y", default=5, min=0, max=5000) + reports_x: IntProperty(name="Reports X", default=5, min=0, max=5000) + + assetbar_on: BoolProperty(name="Assetbar On", default=False) + turn_off: BoolProperty(name="Turn Off", default=False) + + mouse_x: IntProperty(name="Mouse X", default=0) + mouse_y: IntProperty(name="Mouse Y", default=0) + + active_index: IntProperty(name="Active Index", default=-3) + scrolloffset: IntProperty(name="Scroll Offset", default=0) + drawoffset: IntProperty(name="Draw Offset", default=0) + + dragging: BoolProperty(name="Dragging", default=False) + drag_init: BoolProperty(name="Drag Initialisation", default=False) + drag_length: IntProperty(name="Drag length", default=0) + draw_drag_image: BoolProperty(name="Draw Drag Image", default=False) + draw_snapped_bounds: BoolProperty(name="Draw Snapped Bounds", default=False) + + snapped_location: FloatVectorProperty(name="Snapped Location", default=(0, 0, 0)) + snapped_bbox_min: FloatVectorProperty(name="Snapped Bbox Min", default=(0, 0, 0)) + snapped_bbox_max: FloatVectorProperty(name="Snapped Bbox Max", default=(0, 0, 0)) + snapped_normal: FloatVectorProperty(name="Snapped Normal", default=(0, 0, 0)) + + snapped_rotation: FloatVectorProperty(name="Snapped Rotation", default=(0, 0, 0), subtype='QUATERNION') + + has_hit: BoolProperty(name="has_hit", default=False) + thumbnail_image = StringProperty( + name="Thumbnail Image", + description="", + default=paths.get_addon_thumbnail_path('thumbnail_notready.jpg')) + + #### rating UI props + rating_ui_scale = ui_scale + + rating_button_on: BoolProperty(name="Rating Button On", default=True) + rating_menu_on: BoolProperty(name="Rating Menu On", default=False) + rating_on: BoolProperty(name="Rating on", default=True) + + rating_button_width: IntProperty(name="Rating Button Width", default=50 * ui_scale) + rating_button_height: IntProperty(name="Rating Button Height", default=50 * ui_scale) + + rating_x: IntProperty(name="Rating UI X", default=10) + rating_y: IntProperty(name="Rating UI Y", default=10) + + rating_ui_width: IntProperty(name="Rating UI Width", default=rating_ui_scale * 600) + rating_ui_height: IntProperty(name="Rating UI Heightt", default=rating_ui_scale * 256) + + quality_stars_x: IntProperty(name="Rating UI Stars X", default=rating_ui_scale * 90) + quality_stars_y: IntProperty(name="Rating UI Stars Y", default=rating_ui_scale * 190) + + star_size: IntProperty(name="Star Size", default=rating_ui_scale * 50) + + workhours_bar_slider_size: IntProperty(name="Workhours Bar Slider Size", default=rating_ui_scale * 30) + + workhours_bar_x: IntProperty(name="Workhours Bar X", default=rating_ui_scale * (100 - 15)) + workhours_bar_y: IntProperty(name="Workhours Bar Y", default=rating_ui_scale * (45 - 15)) + + workhours_bar_x_max: IntProperty(name="Workhours Bar X Max", default=rating_ui_scale * (480 - 15)) + + dragging_rating: BoolProperty(name="Dragging Rating", default=False) + dragging_rating_quality: BoolProperty(name="Dragging Rating Quality", default=False) + dragging_rating_work_hours: BoolProperty(name="Dragging Rating Work Hours", default=False) + last_rating_time: FloatProperty(name="Last Rating Time", default=0.0) + + +class BlenderKitCommonSearchProps(object): + # STATES + is_searching: BoolProperty(name="Searching", description="search is currently running (internal)", default=False) + is_downloading: BoolProperty(name="Downloading", description="download is currently running (internal)", + default=False) + search_done: BoolProperty(name="Search Completed", description="at least one search did run (internal)", + default=False) + search_error: BoolProperty(name="Search Error", description="last search had an error", default=False) + report: StringProperty( + name="Report", + description="errors and messages", + default="") + + +def name_update(self, context): + ''' checks for name change, because it decides if whole asset has to be re-uploaded. Name is stored in the blend file + and that's the reason.''' + props = utils.get_upload_props() + if props.name_old != props.name: + props.name_changed = True + props.name_old = props.name + nname = props.name.strip() + nname = nname.replace('_', ' ') + if nname.isupper(): + nname = nname.lower() + nname = nname[0].upper() + nname[1:] + props.name = nname + + +def update_tags(self, context): + props = utils.get_upload_props() + + commasep = props.tags.split(',') + ntags = [] + for tag in commasep: + if len(tag) > 19: + short_tags = tag.split(' ') + for short_tag in short_tags: + if len(short_tag) > 19: + short_tag = short_tag[:18] + ntags.append(short_tag) + else: + ntags.append(tag) + if len(ntags) == 1: + ntags = ntags[0].split(' ') + ns = '' + for t in ntags: + if t != '': + ns += t + ',' + ns = ns[:-1] + if props.tags != ns: + props.tags = ns + + +class BlenderKitCommonUploadProps(object): + id: StringProperty( + name="Asset Version Id", + description="Unique name of the asset version(hidden)", + default="") + asset_base_id: StringProperty( + name="Asset Base Id", + description="Unique name of the asset (hidden)", + default="") + name: StringProperty( + name="Name", + description="Main name of the asset", + default="", + update=name_update + ) + # this is to store name for purpose of checking if name has changed. + name_old: StringProperty( + name="Old Name", + description="Old name of the asset", + default="", + ) + + description: StringProperty( + name="Description", + description="Description of the asset.", + default="") + tags: StringProperty( + name="Tags", + description="List of tags, separated by commas (optional)", + default="", + update=update_tags + ) + + name_changed: BoolProperty(name="Name Changed", + description="Name has changed, the asset has to be re-uploaded with all data", + default=False) + + pbr: BoolProperty(name="Pure PBR Compatible", + description="Is compatible with PBR standard. This means only image textures are used with no" + " procedurals and no color correction, only pbr shader is used.", + default=False) + + pbr_type: EnumProperty( + name="PBR Type", + items=pbr_types, + description="PBR type", + default="METALLIC", + ) + license: EnumProperty( + items=licenses, + default='royalty_free', + description='License. Please read our help for choosing the right licenses', + ) + + is_free: BoolProperty(name="Free for Everyone", + description="You consent you want to release this asset as free for everyone.", + default=False) + + uploading: BoolProperty(name="Uploading", + description="True when background process is running", + default=False, + update=autothumb.update_upload_material_preview) + upload_state: StringProperty( + name="State Of Upload", + description="bg process reports for upload", + default='') + + has_thumbnail: BoolProperty(name="Has Thumbnail", description="True when thumbnail was checked and loaded", + default=False) + + thumbnail_generating_state: StringProperty( + name="Thumbnail Generating State", + description="bg process reports for thumbnail generation", + default='Please add thumbnail(jpg, at least 512x512)') + + report: StringProperty( + name="Missing Upload Properties", + description="used to write down what's missing", + default='') + + category: EnumProperty( + name="Category", + description="main category to put into", + items=get_category_enums + ) + subcategory: EnumProperty( + name="Subcategory", + description="main category to put into", + items=get_subcategory_enums + ) + + +class BlenderKitRatingProps(PropertyGroup): + rating_quality: IntProperty(name="Quality", description="quality of the material", default=0, min=-1, max=10) + rating_work_hours: FloatProperty(name="Work Hours", description="How many hours did this work take?", default=0.01, + min=0.0, max=1000 + ) + rating_complexity: IntProperty(name="Complexity", + description="Complexity is a number estimating how much work was spent on the asset.aaa", + default=0, min=0, max=10) + rating_virtual_price: FloatProperty(name="Virtual Price", + description="How much would you pay for this object if buing it?", + default=0, min=0, max=10000) + rating_problems: StringProperty( + name="Problems", + description="Problems found/ why did you take points down - this will be available for the author." + " As short as possible", + default="", + ) + rating_compliments: StringProperty( + name="Compliments", + description="Comliments - let the author know you like his work! " + " As short as possible", + default="", + ) + + +class BlenderKitMaterialSearchProps(PropertyGroup, BlenderKitCommonSearchProps): + search_keywords: StringProperty( + name="Search", + description="Search for these keywords", + default="", + update=search.search_update + ) + search_style: EnumProperty( + name="Style", + items=search_material_styles, + description="Style of material", + default="ANY", + ) + search_style_other: StringProperty( + name="Style Other", + description="Style not in the list", + default="", + ) + search_engine: EnumProperty( + name='Engine', + items=engines, + default='NONE', + description='Output engine', + ) + search_engine_other: StringProperty( + name="Engine", + description="engine not specified by addon", + default="", + ) + automap: BoolProperty(name="Auto-Map", + description="reset object texture space and also add automatically a cube mapped UV " + "to the object. \n this allows most materials to apply instantly to any mesh", + default=True) + + +class BlenderKitMaterialUploadProps(PropertyGroup, BlenderKitCommonUploadProps): + style: EnumProperty( + name="Style", + items=material_styles, + description="Style of material", + default="REALISTIC", + ) + style_other: StringProperty( + name="Style Other", + description="Style not in the list", + default="", + ) + engine: EnumProperty( + name='Engine', + items=engines, + default='CYCLES', + description='Output engine', + ) + engine_other: StringProperty( + name="Engine Other", + description="engine not specified by addon", + default="", + ) + + shaders: StringProperty( + name="Shaders Used", + description="shaders used in asset, autofilled", + default="", + ) + + uv: BoolProperty(name="Needs UV", description="needs an UV set", default=False) + # printable_3d : BoolProperty( name = "3d printable", description = "can be 3d printed", default = False) + animated: BoolProperty(name="Animated", description="is animated", default=False) + texture_resolution_min: IntProperty(name="Texture Resolution Min", description="texture resolution minimum", + default=0) + texture_resolution_max: IntProperty(name="Texture Resolution Max", description="texture resolution maximum", + default=0) + + texture_size_meters: FloatProperty(name="Texture Size in Meters", description="face count, autofilled", + default=1.0, min=0) + + thumbnail_scale: FloatProperty(name="Thumbnail Object Size", + description="size of material preview object in meters " + "- change for materials that look better at sizes different than 1m", + default=1, min=0.00001, max=10) + thumbnail_background: BoolProperty(name="Thumbnail Background", + description="For refractive materials, you might need a background. " + "Don't use if thumbnail looks good without it!", + default=False) + thumbnail_background_lightness: FloatProperty(name="Thumbnail Background Lightness", + description="set to make your material stand out", default=.9, + min=0.00001, max=1) + thumbnail_samples: IntProperty(name="Cycles Samples", + description="cycles samples setting", default=150, + min=5, max=5000) + thumbnail_denoising: BoolProperty(name="Use Denoising", + description="Use denoising", default=True) + adaptive_subdivision: BoolProperty(name="Adaptive Subdivide", + description="Use adaptive displacement subdivision", default=False) + + thumbnail_generator_type: EnumProperty( + name="Thumbnail Style", + items=( + ('BALL', 'Ball', ""), + ('CUBE', 'Cube', 'cube'), + ('FLUID', 'Fluid', 'Fluid'), + ('CLOTH', 'Cloth', 'Cloth'), + ('HAIR', 'Hair', 'Hair ') + ), + description="Style of asset", + default="BALL", + ) + + thumbnail: StringProperty( + name="Thumbnail", + description="Path to the thumbnail - 512x512 .jpg image", + subtype='FILE_PATH', + default="", + update=autothumb.update_upload_material_preview) + + is_generating_thumbnail: BoolProperty(name="Generating Thumbnail", + description="True when background process is running", default=False, + update=autothumb.update_upload_material_preview) + + +class BlenderKitTextureUploadProps(PropertyGroup, BlenderKitCommonUploadProps): + style: EnumProperty( + name="Style", + items=material_styles, + description="Style of texture", + default="REALISTIC", + ) + style_other: StringProperty( + name="Style Other", + description="Style not in the list", + default="", + ) + + pbr: BoolProperty(name="PBR Compatible", description="Is compatible with PBR standard", default=False) + + # printable_3d : BoolProperty( name = "3d printable", description = "can be 3d printed", default = False) + animated: BoolProperty(name="Animated", description="is animated", default=False) + resolution: IntProperty(name="Texture Resolution", description="texture resolution", default=0) + + +class BlenderKitBrushSearchProps(PropertyGroup, BlenderKitCommonSearchProps): + search_keywords: StringProperty( + name="Search", + description="Search for these keywords", + default="", + update=search.search_update + ) + + +class BlenderKitBrushUploadProps(PropertyGroup, BlenderKitCommonUploadProps): + mode: EnumProperty( + name="Mode", + items=( + ('IMAGE', 'Texture paint', "Texture brush"), + ('SCULPT', 'Sculpt', 'Sculpt brush'), + ('VERTEX', 'Vertex paint', 'Vertex paint brush'), + ('WEIGHT', 'Weight paint', 'Weight paint brush'), + ), + description="Mode where the brush works", + default="SCULPT", + ) + + +# upload properties +class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps): + style: EnumProperty( + name="Style", + items=model_styles, + description="Style of asset", + default="REALISTIC", + ) + style_other: StringProperty( + name="Style Other", + description="Style not in the list", + default="", + ) + engine: EnumProperty( + name='Engine', + items=engines, + default='CYCLES', + description='Output engine', + ) + + production_level: EnumProperty( + name='Production Level', + items=( + ('FINISHED', 'Finished', 'Render or animation ready asset'), + ('TEMPLATE', 'Template', 'Asset intended to help in creation of something else'), + ), + default='FINISHED', + description='Production state of the asset, \n also template should be actually finished, \n' + 'just the nature of it can be a template, like a thumbnailer scene, \n ' + 'finished mesh topology as start for modelling or similar.', + ) + + engine_other: StringProperty( + name="Engine", + description="engine not specified by addon", + default="", + ) + + engine1: EnumProperty( + name='2nd Engine', + items=engines, + default='NONE', + description='Output engine', + ) + engine2: EnumProperty( + name='3rd Engine', + items=engines, + default='NONE', + description='Output engine', + ) + engine3: EnumProperty( + name='4th Engine', + items=engines, + default='NONE', + description='Output engine', + ) + + manufacturer: StringProperty( + name="Manufacturer", + description="Manufacturer, company making a design peace or product.", + default="", + ) + + designer: StringProperty( + name="Designer", + description="Author of the original design piece depicted.", + default="", + ) + + design_collection: StringProperty( + name="Design Collection", + description="Fill if this piece is part of a design collection.", + default="", + ) + + design_variant: StringProperty( + name="Variant", + description="Colour or material variant of the product.", + default="", + ) + + thumbnail: StringProperty( + name="Thumbnail", + description="Path to the thumbnail - 512x512 .jpg image", + subtype='FILE_PATH', + default="", + update=autothumb.update_upload_model_preview) + + thumbnail_background_lightness: FloatProperty(name="Thumbnail Background Lightness", + description="set to make your material stand out", default=.9, + min=0.01, max=10) + + thumbnail_angle: EnumProperty( + name='Thumbnail Angle', + items=thumbnail_angles, + default='DEFAULT', + description='thumbnailer angle', + ) + + thumbnail_snap_to: EnumProperty( + name='Model Snaps To:', + items=thumbnail_snap, + default='GROUND', + description='typical placing of the interior. Leave on ground for most objects that respect gravity :)', + ) + + thumbnail_samples: IntProperty(name="Cycles Samples", + description="cycles samples setting", default=200, + min=5, max=5000) + thumbnail_denoising: BoolProperty(name="Use Denoising", + description="Use denoising", default=True) + + use_design_year: BoolProperty(name="Use Design Year", + description="When this thing came into world for the first time\n" + " e.g. for dinosaur, you set -240 million years ;) ", + default=False) + design_year: IntProperty(name="Design Year", description="when was this item designed", default=1960) + # use_age : BoolProperty( name = "use item age", description = "use item age", default = False) + condition: EnumProperty( + items=conditions, + default='UNSPECIFIED', + description='age of the object', + ) + + adult: BoolProperty(name="Adult Content", description="adult content", default=False) + + work_hours: FloatProperty(name="Work Hours", description="How long did it take you to finish the asset?", + default=0.0, min=0.0, max=8760) + + modifiers: StringProperty( + name="Modifiers Used", + description="if you need specific modifiers, autofilled", + default="", + ) + + materials: StringProperty( + name="Material Names", + description="names of materials in the file, autofilled", + default="", + ) + shaders: StringProperty( + name="Shaders Used", + description="shaders used in asset, autofilled", + default="", + ) + + dimensions: FloatVectorProperty( + name="Dimensions", + description="dimensions of the whole asset hierarchy", + default=(0, 0, 0), + ) + bbox_min: FloatVectorProperty( + name="Bbox Min", + description="dimensions of the whole asset hierarchy", + default=(-.25, -.25, 0), + ) + bbox_max: FloatVectorProperty( + name="Bbox Max", + description="dimensions of the whole asset hierarchy", + default=(.25, .25, .5), + ) + + texture_resolution_min: IntProperty(name="Texture Resolution Min", + description="texture resolution min, autofilled", default=0) + texture_resolution_max: IntProperty(name="Texture Resolution Max", + description="texture resolution max, autofilled", default=0) + + pbr: BoolProperty(name="PBR Compatible", description="Is compatible with PBR standard", default=False) + + uv: BoolProperty(name="Has UV", description="has an UV set", default=False) + # printable_3d : BoolProperty( name = "3d printable", description = "can be 3d printed", default = False) + animated: BoolProperty(name="Animated", description="is animated", default=False) + face_count: IntProperty(name="Face count", description="face count, autofilled", default=0) + face_count_render: IntProperty(name="Render Face Count", description="render face count, autofilled", default=0) + + object_count: IntProperty(name="Number of Objects", description="how many objects are in the asset, autofilled", + default=0) + mesh_poly_type: EnumProperty( + name='Dominant Poly Type', + items=mesh_poly_types, + default='OTHER', + description='', + ) + + manifold: BoolProperty(name="Manifold", description="asset is manifold, autofilled", default=False) + + rig: BoolProperty(name="Rig", description="asset is rigged, autofilled", default=False) + simulation: BoolProperty(name="Simulation", description="asset uses simulation, autofilled", default=False) + ''' + filepath : StringProperty( + name="Filepath", + description="file path", + default="", + ) + ''' + + # THUMBNAIL STATES + is_generating_thumbnail: BoolProperty(name="Generating Thumbnail", + description="True when background process is running", default=False, + update=autothumb.update_upload_model_preview) + + has_autotags: BoolProperty(name="Has Autotagging Done", description="True when autotagging done", default=False) + + +class BlenderKitSceneUploadProps(PropertyGroup, BlenderKitCommonUploadProps): + style: EnumProperty( + name="Style", + items=model_styles, + description="Style of asset", + default="REALISTIC", + ) + style_other: StringProperty( + name="Style Other", + description="Style not in the list", + default="", + ) + engine: EnumProperty( + name='Engine', + items=engines, + default='CYCLES', + description='Output engine', + ) + + production_level: EnumProperty( + name='Production Level', + items=( + ('FINISHED', 'Finished', 'Render or animation ready asset'), + ('TEMPLATE', 'Template', 'Asset intended to help in creation of something else'), + ), + default='FINISHED', + description='Production state of the asset, \n also template should be actually finished, \n' + 'just the nature of it can be a template, like a thumbnailer scene, \n ' + 'finished mesh topology as start for modelling or similar.', + ) + + engine_other: StringProperty( + name="Engine", + description="engine not specified by addon", + default="", + ) + + engine1: EnumProperty( + name='2nd Engine', + items=engines, + default='NONE', + description='Output engine', + ) + engine2: EnumProperty( + name='3rd Engine', + items=engines, + default='NONE', + description='Output engine', + ) + engine3: EnumProperty( + name='4th Engine', + items=engines, + default='NONE', + description='Output engine', + ) + + thumbnail: StringProperty( + name="Thumbnail", + description="Path to the thumbnail - 512x512 .jpg image", + subtype='FILE_PATH', + default="", + update=autothumb.update_upload_scene_preview) + + use_design_year: BoolProperty(name="Use Design Year", + description="When this thing came into world for the first time\n" + " e.g. for dinosaur, you set -240 million years ;) ", + default=False) + design_year: IntProperty(name="Design Year", description="when was this item designed", default=1960) + # use_age : BoolProperty( name = "use item age", description = "use item age", default = False) + condition: EnumProperty( + items=conditions, + default='UNSPECIFIED', + description='age of the object', + ) + + adult: BoolProperty(name="Adult Content", description="adult content", default=False) + + work_hours: FloatProperty(name="Work Hours", description="How long did it take you to finish the asset?", + default=0.0, min=0.0, max=8760) + + modifiers: StringProperty( + name="Modifiers Used", + description="if you need specific modifiers, autofilled", + default="", + ) + + materials: StringProperty( + name="Material Names", + description="names of materials in the file, autofilled", + default="", + ) + shaders: StringProperty( + name="Shaders Used", + description="shaders used in asset, autofilled", + default="", + ) + + dimensions: FloatVectorProperty( + name="Dimensions", + description="dimensions of the whole asset hierarchy", + default=(0, 0, 0), + ) + bbox_min: FloatVectorProperty( + name="Dimensions", + description="dimensions of the whole asset hierarchy", + default=(-.25, -.25, 0), + ) + bbox_max: FloatVectorProperty( + name="Dimensions", + description="dimensions of the whole asset hierarchy", + default=(.25, .25, .5), + ) + + texture_resolution_min: IntProperty(name="Texture Eesolution Min", + description="texture resolution min, autofilled", default=0) + texture_resolution_max: IntProperty(name="Texture Eesolution Max", + description="texture resolution max, autofilled", default=0) + + pbr: BoolProperty(name="PBR Compatible", description="Is compatible with PBR standard", default=False) + + uv: BoolProperty(name="Has UV", description="has an UV set", default=False) + # printable_3d : BoolProperty( name = "3d printable", description = "can be 3d printed", default = False) + animated: BoolProperty(name="Animated", description="is animated", default=False) + face_count: IntProperty(name="Face Count", description="face count, autofilled", default=0) + face_count_render: IntProperty(name="Render Face Count", description="render face count, autofilled", default=0) + + object_count: IntProperty(name="Number of Objects", description="how many objects are in the asset, autofilled", + default=0) + mesh_poly_type: EnumProperty( + name='Dominant Poly Type', + items=mesh_poly_types, + default='OTHER', + description='', + ) + + rig: BoolProperty(name="Rig", description="asset is rigged, autofilled", default=False) + simulation: BoolProperty(name="Simulation", description="asset uses simulation, autofilled", default=False) + + # THUMBNAIL STATES + is_generating_thumbnail: BoolProperty(name="Generating Thumbnail", + description="True when background process is running", default=False, + update=autothumb.update_upload_model_preview) + + has_autotags: BoolProperty(name="Has Autotagging Done", description="True when autotagging done", default=False) + + +class BlenderKitModelSearchProps(PropertyGroup, BlenderKitCommonSearchProps): + search_keywords: StringProperty( + name="Search", + description="Search for these keywords", + default="", + update=search.search_update + ) + search_style: EnumProperty( + name="Style", + items=search_model_styles, + description="keywords defining style (realistic, painted, polygonal, other)", + default="ANY", + update=search.search_update + ) + search_style_other: StringProperty( + name="Style", + description="Search style - other", + default="", + update=search.search_update + ) + search_engine: EnumProperty( + items=engines, + default='CYCLES', + description='Output engine', + update=search.search_update + ) + search_engine_other: StringProperty( + name="Engine", + description="engine not specified by addon", + default="", + update=search.search_update + ) + + search_advanced: BoolProperty(name="Advanced Search Options", description="use advanced search properties", + default=False) + + # CONDITION + search_condition: EnumProperty( + items=conditions, + default='UNSPECIFIED', + description='condition of the object', + update=search.search_update + ) + + search_adult: BoolProperty( + name="Adult Content", + description="You're adult and agree with searching adult content", + default=False, + update=search.search_update + ) + + # DESIGN YEAR + search_design_year: BoolProperty(name="Sesigned in Year", + description="when the object was approximately designed", + default=False) + + search_design_year_min: IntProperty(name="Min Age", + description="when the object was approximately designed", + default=1950, min=-100000000, max=1000000000) + + search_design_year_max: IntProperty(name="Max Age", + description="when the object was approximately designed", + default=2017, + min=0, + max=10000000) + + # TEXTURE RESOLUTION + search_texture_resolution: BoolProperty(name="Texture Resolution", + description="Span of the texture resolutions", + default=False) + + search_texture_resolution_min: IntProperty(name="Min Texture Resolution", + description="when the object was approximately designed", + default=256, + min=0, + max=32768) + + search_texture_resolution_max: IntProperty(name="Max Texture Resolution", + description="when the object was approximately designed", + default=4096, + min=0, + max=32768) + + # POLYCOUNT + search_polycount: BoolProperty(name="Use Polycount", + description="use polycount of object search tag", + default=False) + + search_polycount_min: IntProperty(name="Min Polycount", + description="polycount of the asset minimum", + default=0, + min=0, + max=100000000) + + search_polycount_max: IntProperty(name="Max Polycount", + description="polycount of the asset maximum", + default=100000000, + min=0, + max=100000000) + + append_method: EnumProperty( + name="Import Method", + items=( + ('LINK_GROUP', 'Link Group', ''), + ('APPEND_OBJECTS', 'Append Objects', ''), + ), + description="choose if the assets will be linked or appended", + default="LINK_GROUP" + ) + append_link: EnumProperty( + name="How to Attach", + items=( + ('LINK', 'Link', ''), + ('APPEND', 'Append', ''), + ), + description="choose if the assets will be linked or appended", + default="LINK" + ) + import_as: EnumProperty( + name="Import as", + items=( + ('GROUP', 'group', ''), + ('INDIVIDUAL', 'objects', ''), + + ), + description="choose if the assets will be linked or appended", + default="GROUP" + ) + randomize_rotation: BoolProperty(name='Randomize Rotation', + description="randomize rotation at placement", + default=False) + randomize_rotation_amount: FloatProperty(name="Randomization Max Angle", + description="maximum angle for random rotation", + default=math.pi / 36, + min=0, + max=2 * math.pi, + subtype='ANGLE') + offset_rotation_amount: FloatProperty(name="Offset Rotation", + description="offset rotation, hidden prop", + default=0, + min=0, + max=360, + subtype='ANGLE') + offset_rotation_step: FloatProperty(name="Offset Rotation Step", + description="offset rotation, hidden prop", + default=math.pi / 2, + min=0, + max=180, + subtype='ANGLE') + + +class BlenderKitSceneSearchProps(PropertyGroup, BlenderKitCommonSearchProps): + search_keywords: StringProperty( + name="Search", + description="Search for these keywords", + default="", + update=search.search_update + ) + search_style: EnumProperty( + name="Style", + items=search_model_styles, + description="keywords defining style (realistic, painted, polygonal, other)", + default="ANY", + update=search.search_update + ) + search_style_other: StringProperty( + name="Style", + description="Search style - other", + default="", + update=search.search_update + ) + search_engine: EnumProperty( + items=engines, + default='CYCLES', + description='Output engine', + update=search.search_update + ) + search_engine_other: StringProperty( + name="Engine", + description="engine not specified by addon", + default="", + update=search.search_update + ) + + +class BlenderKitAddonPreferences(AddonPreferences): + # this must match the addon name, use '__package__' + # when defining this in a submodule of a python package. + bl_idname = __name__ + from os.path import expanduser + home = expanduser("~") + default_global_dict = home + os.sep + 'blenderkit_data' + + api_key: StringProperty( + name="BlenderKit API Key", + description="Your blenderkit API Key. Get it from your page on the website.", + default="", + subtype="PASSWORD", + update=utils.save_prefs + ) + + global_dir: StringProperty( + name="Global Files Directory", + description="Global storage for your assets, will use subdirectories for the contents.", + subtype='DIR_PATH', + default=default_global_dict, + update=utils.save_prefs + ) + + project_subdir: StringProperty( + name="Project Assets Subdirectory", + description="where data will be stored for individual projects.", + subtype='DIR_PATH', + default="//assets", + ) + + directory_behaviour: EnumProperty( + name="Use Directories", + items=( + ('BOTH', 'Global and subdir', + 'store files both in global lib and subdirectory of current project. ' + 'Warning - each file can be many times on your harddrive, but helps you keep your projects in one piece.'), + ('GLOBAL', 'Global', + "store downloaded files only in global directory. \n " + "This can bring problems when moving your projects, \n" + "since assets won't be in subdirectory of current project"), + ('LOCAL', 'Local', + 'store downloaded files only in local directory.\n' + ' This can use more bandwidth when you reuse assets in different projects. ') + + ), + description="Which directories will be used for storing downloaded data", + default="BOTH", + ) + thumbnail_use_gpu: BoolProperty( + name="Use GPU for Thumbnails Rendering", + description="By default this is off so you can continue your work without any lag.", + default=True + ) + + panel_behaviour: EnumProperty( + name="Panels Locations", + items=( + ('BOTH', 'Both Types', + ''), + ('UNIFIED', 'Unified 3d View Panel', + ""), + ('LOCAL', 'Relative to Data', + '') + + ), + description="Which directories will be used for storing downloaded data", + default="UNIFIED", + ) + + max_assetbar_rows: IntProperty(name="Max Assetbar Rows", + description="max rows of assetbar in the 3d view", + default=1, + min=0, + max=20) + + # allow_proximity : BoolProperty( + # name="allow proximity data reports", + # description="This sends anonymized proximity data \n \ + # and allows us to make relations between database objects \n \ + # without user interaction.", + # default=False + # ) + + def draw(self, context): + layout = self.layout + + if self.api_key.strip() == '': + op = layout.operator("wm.url_open", text="Register online and get your API Key", + icon='QUESTION') + op.url = paths.BLENDERKIT_SIGNUP_URL + layout.prop(self, "api_key", text='Your API Key') + # layout.label('After you paste API Key, categories are downloaded, so blender will freeze for a few seconds.') + layout.prop(self, "global_dir") + layout.prop(self, "project_subdir") + # layout.prop(self, "temp_dir") + layout.prop(self, "directory_behaviour") + layout.prop(self, "thumbnail_use_gpu") + # layout.prop(self, "allow_proximity") + # layout.prop(self, "panel_behaviour") + layout.prop(self, "max_assetbar_rows") + + +# registration +classes = ( + + BlenderKitAddonPreferences, + BlenderKitUIProps, + + BlenderKitModelSearchProps, + BlenderKitModelUploadProps, + + BlenderKitSceneSearchProps, + BlenderKitSceneUploadProps, + + BlenderKitMaterialUploadProps, + BlenderKitMaterialSearchProps, + + BlenderKitTextureUploadProps, + + BlenderKitBrushSearchProps, + BlenderKitBrushUploadProps, + + BlenderKitRatingProps, +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + + bpy.types.Scene.blenderkitUI = PointerProperty( + type=BlenderKitUIProps) + + # MODELS + bpy.types.Scene.blenderkit_models = PointerProperty( + type=BlenderKitModelSearchProps) + bpy.types.Object.blenderkit = PointerProperty( # for uploads, not now... + type=BlenderKitModelUploadProps) + bpy.types.Object.bkit_ratings = PointerProperty( # for uploads, not now... + type=BlenderKitRatingProps) + + # SCENES + bpy.types.Scene.blenderkit_scene = PointerProperty( + type=BlenderKitSceneSearchProps) + bpy.types.Scene.blenderkit = PointerProperty( # for uploads, not now... + type=BlenderKitSceneUploadProps) + bpy.types.Scene.bkit_ratings = PointerProperty( # for uploads, not now... + type=BlenderKitRatingProps) + + # MATERIALS + bpy.types.Scene.blenderkit_mat = PointerProperty( + type=BlenderKitMaterialSearchProps) + bpy.types.Material.blenderkit = PointerProperty( # for uploads, not now... + type=BlenderKitMaterialUploadProps) + bpy.types.Material.bkit_ratings = PointerProperty( # for uploads, not now... + type=BlenderKitRatingProps) + + # BRUSHES + bpy.types.Scene.blenderkit_brush = PointerProperty( + type=BlenderKitBrushSearchProps) + bpy.types.Brush.blenderkit = PointerProperty( # for uploads, not now... + type=BlenderKitBrushUploadProps) + bpy.types.Brush.bkit_ratings = PointerProperty( # for uploads, not now... + type=BlenderKitRatingProps) + + search.register_search() + asset_inspector.register_asset_inspector() + download.register_download() + upload.register_upload() + ratings.register_ratings() + autothumb.register_thumbnailer() + ui.register_ui() + ui_panels.register_ui_panels() + bg_blender.register() + bpy.app.handlers.load_post.append(scene_load) + utils.load_prefs() + overrides.register_overrides() + + +def unregister(): + for cls in classes: + bpy.utils.unregister_class(cls) + + del bpy.types.Scene.blenderkit_models + del bpy.types.Scene.blenderkit_scene + del bpy.types.Scene.blenderkit_brush + del bpy.types.Scene.blenderkit_mat + + del bpy.types.Scene.blenderkit + del bpy.types.Object.blenderkit + del bpy.types.Material.blenderkit + del bpy.types.Brush.blenderkit + + search.unregister_search() + asset_inspector.unregister_asset_inspector() + download.unregister_download() + upload.unregister_upload() + ratings.unregister_ratings() + autothumb.unregister_thumbnailer() + ui.unregister_ui() + ui_panels.unregister_ui_panels() + bg_blender.unregister() + overrides.unregister_overrides() diff --git a/blenderkit/append_link.py b/blenderkit/append_link.py new file mode 100644 index 00000000..7be4d795 --- /dev/null +++ b/blenderkit/append_link.py @@ -0,0 +1,193 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +import bpy +import os, uuid +from blenderkit import utils + + +def append_brush(file_name, brushname=None, link=False, fake_user=True): + '''append a brush''' + with bpy.data.libraries.load(file_name, link=link, relative=True) as (data_from, data_to): + for m in data_from.brushes: + if m == brushname or brushname is None: + data_to.brushes = [m] + brushname = m + brush = bpy.data.brushes[brushname] + if fake_user: + brush.use_fake_user = True + return brush + + +def append_material(file_name, matname=None, link=False, fake_user=True): + '''append a material type asset''' + # first, we have to check if there is a material with same name + # in previous step there's check if the imported material + # is allready in the scene, so we know same name != same material + + mats_before = bpy.data.materials.keys() + + with bpy.data.libraries.load(file_name, link=link, relative=True) as (data_from, data_to): + for m in data_from.materials: + if m == matname or matname is None: + data_to.materials = [m] + print(m, type(m)) + matname = m + break; + + # we have to find the new material :( + for mname in bpy.data.materials.keys(): + if mname not in mats_before: + mat = bpy.data.materials[mname] + break + + if fake_user: + mat.use_fake_user = True + + return mat + + +def append_scene(file_name, scenename=None, link=False, fake_user=False): + '''append a scene type asset''' + with bpy.data.libraries.load(file_name, link=link, relative=True) as (data_from, data_to): + for s in data_from.scenes: + if s == scenename or scenename is None: + data_to.scenes = [s] + scenename = s + scene = bpy.data.scenes[scenename] + if fake_user: + scene.use_fake_user = True + # scene has to have a new uuid, so user reports aren't screwed. + scene['uuid'] = str(uuid.uuid4()) + return scene + + +def link_group(file_name, obnames=[], location=(0, 0, 0), link=False, **kwargs): + '''link an instanced group - model type asset''' + sel = utils.selection_get() + bpy.ops.wm.link(directory=file_name + "/Collection/", filename=kwargs['name'], link=link, instance_collections=True, + autoselect=True) + + main_object = bpy.context.active_object + if kwargs.get('rotation') is not None: + main_object.rotation_euler = kwargs['rotation'] + main_object.location = location + utils.selection_set(sel) + return main_object, [] + + +def append_particle_system(file_name, obnames=[], location=(0, 0, 0), link=False, **kwargs): + '''link an instanced group - model type asset''' + + pss = [] + with bpy.data.libraries.load(file_name, link=link, relative=True) as (data_from, data_to): + for ps in data_from.particles: + pss.append(ps) + data_to.particles = pss + + s = bpy.context.scene + sel = utils.selection_get() + + target_object = bpy.context.scene.objects.get(kwargs['target_object']) + if target_object is not None and target_object.type == 'MESH': + target_object.select_set(True) + bpy.context.view_layer.objects.active = target_object + + for ps in pss: + # now let's tune this ps to the particular objects area: + totarea = 0 + for p in target_object.data.polygons: + totarea += p.area + count = int(ps.count * totarea) + if ps.child_type in ('INTERPOLATED', 'SIMPLE'): + total_count = count * ps.rendered_child_count + disp_count = count * ps.child_nbr + else: + total_count = count + threshold = 2000 + total_max_threshold = 50000 + # emitting too many parent particles just kills blender now: + if count > total_max_threshold: + ratio = round(count / total_max_threshold) + + if ps.child_type in ('INTERPOLATED', 'SIMPLE'): + ps.rendered_child_count *= ratio + else: + ps.child_type = 'INTERPOLATED' + ps.rendered_child_count = ratio + count = max(2, int(count / ratio)) + ps.display_percentage = min(ps.display_percentage, max(1, int(100 * threshold / total_count))) + + print('count', count) + print('total count', total_count) + ps.count = count + print('got here') + bpy.ops.object.particle_system_add() + target_object.particle_systems[-1].settings = ps + + target_object.select_set(False) + utils.selection_set(sel) + return target_object, [] + + +def append_objects(file_name, obnames=[], location=(0, 0, 0), link=False, **kwargs): + '''append objects into scene individually''' + with bpy.data.libraries.load(file_name, link=link, relative=True) as (data_from, data_to): + sobs = [] + for ob in data_from.objects: + if ob in obnames or obnames == []: + sobs.append(ob) + data_to.objects = sobs + # data_to.objects = data_from.objects#[name for name in data_from.objects if name.startswith("house")] + + # link them to scene + scene = bpy.context.scene + sel = utils.selection_get() + bpy.ops.object.select_all(action='DESELECT') + + return_obs = [] # this might not be needed, but better be sure to rewrite the list. + main_object = None + hidden_objects = [] + # + for obj in data_to.objects: + if obj is not None: + # if obj.name not in scene.objects: + scene.collection.objects.link(obj) + if obj.parent is None: + obj.location = location + main_object = obj + obj.select_set(True) + # we need to unhide object so make_local op can use those too. + if link == True: + if obj.hide_viewport: + hidden_objects.append(obj) + obj.hide_viewport = False + return_obs.append(obj) + + # Only after all objects are in scene! Otherwise gets broken relationships + if link == True: + bpy.ops.object.make_local(type='SELECT_OBJECT') + for ob in hidden_objects: + ob.hide_viewport = True + + if kwargs.get('rotation') is not None: + main_object.rotation_euler = kwargs['rotation'] + bpy.ops.object.select_all(action='DESELECT') + + utils.selection_set(sel) + return main_object, return_obs diff --git a/blenderkit/asset_inspector.py b/blenderkit/asset_inspector.py new file mode 100644 index 00000000..7c5d24f7 --- /dev/null +++ b/blenderkit/asset_inspector.py @@ -0,0 +1,376 @@ +# ##### 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 ##### + +if "bpy" in locals(): + import imp + + imp.reload(utils) +else: + from blenderkit import utils + +import bpy, bmesh +import mathutils +import object_print3d_utils +from object_print3d_utils import operators as ops +from mathutils import Vector + +RENDER_OBTYPES = ['MESH', 'CURVE', 'SURFACE', 'METABALL', 'TEXT'] + + +def check_material(props, mat): + e = bpy.context.scene.render.engine + shaders = [] + if e == 'CYCLES': + + if mat.node_tree is not None: + checknodes = mat.node_tree.nodes[:] + while len(checknodes) > 0: + n = checknodes.pop() + if n.type == 'GROUP': # dive deeper here. + checknodes.extend(n.node_tree.nodes) + if len(n.outputs) == 1 and n.outputs[0].type == 'SHADER' and n.type != 'GROUP': + if n.type not in shaders: + shaders.append(n.type) + if n.type == 'TEX_IMAGE': + mattype = 'image based' + if n.image is not None: + + maxres = max(n.image.size[0], n.image.size[1]) + + props.texture_resolution_max = max(props.texture_resolution_max, maxres) + + minres = min(n.image.size[0], n.image.size[1]) + + if props.texture_resolution_min == 0: + props.texture_resolution_min = minres + else: + props.texture_resolution_min = min(props.texture_resolution_min, minres) + + props.shaders = '' + for s in shaders: + if s.startswith('BSDF_'): + s = s[5:] + s = s.lower().replace('_', ' ') + props.shaders += (s + ', ') + + +def check_render_engine(props, obs): + ob = obs[0] + m = None + + e = bpy.context.scene.render.engine + mattype = None + materials = [] + shaders = [] + props.uv = False + + for ob in obs: # TODO , this is duplicated here for other engines, otherwise this should be more clever. + for ms in ob.material_slots: + if ms.material is not None: + m = ms.material + if m.name not in materials: + materials.append(m.name) + if ob.type == 'MESH' and len(ob.data.uv_layers) > 0: + props.uv = True + + if e == 'BLENDER_RENDER': + props.engine = 'BLENDER_INTERNAL' + elif e == 'CYCLES': + + props.engine = 'CYCLES' + + + for mname in materials: + m = bpy.data.materials[mname] + if m is not None and m.node_tree is not None: + checknodes = m.node_tree.nodes[:] + while len(checknodes) > 0: + n = checknodes.pop() + if n.type == 'GROUP': # dive deeper here. + checknodes.extend(n.node_tree.nodes) + if len(n.outputs) == 1 and n.outputs[0].type == 'SHADER' and n.type != 'GROUP': + if n.type not in shaders: + shaders.append(n.type) + if n.type == 'TEX_IMAGE': + mattype = 'image based' + if n.image is not None: + + maxres = max(n.image.size[0], n.image.size[1]) + + props.texture_resolution_max = max(props.texture_resolution_max, maxres) + + minres = min(n.image.size[0], n.image.size[1]) + + if props.texture_resolution_min == 0: + props.texture_resolution_min = minres + else: + props.texture_resolution_min = min(props.texture_resolution_min, minres) + + # if mattype == None: + # mattype = 'procedural' + # tags['material type'] = mattype + + elif e == 'BLENDER_GAME': + props.engine = 'BLENDER_GAME' + + # write to object properties. + print(materials) + print(shaders) + props.materials = '' + props.shaders = '' + for m in materials: + props.materials += (m + ', ') + for s in shaders: + if s.startswith('BSDF_'): + s = s[5:] + s = s.lower() + s = s.replace('_', ' ') + props.shaders += (s + ', ') + print(props.shaders) + + +def check_printable(props, obs): + if len(obs) == 1: + check_cls = ( + ops.Print3DCheckSolid, + ops.Print3DCheckIntersections, + ops.Print3DCheckDegenerate, + ops.Print3DCheckDistorted, + ops.Print3DCheckThick, + ops.Print3DCheckSharp, + # ops.Print3DCheckOverhang, + ) + + ob = obs[0] + + info = [] + for cls in check_cls: + cls.main_check(ob, info) + + printable = True + for item in info: + print(item) + passed = item[0].endswith(' 0') + print('passed', passed) + if not passed: + print(item[0]) + printable = False + + props.printable_3d = printable + + +def check_rig(props, obs): + for ob in obs: + if ob.type == 'ARMATURE': + props.rig = True + + +def check_anim(props, obs): + animated = False + for ob in obs: + if ob.animation_data is not None: + a = ob.animation_data.action + if a is not None: + for c in a.fcurves: + if len(c.keyframe_points) > 1: + animated = True + + # c.keyframe_points.remove(c.keyframe_points[0]) + if animated: + props.animated = True + + +def check_meshprops(props, obs): + ''' checks polycount, manifold, mesh parts (not implemented)''' + fc = 0 + fcr = 0 + tris = 0 + quads = 0 + ngons = 0 + vc = 0 + + edges_counts = {} + manifold = True + + for ob in obs: + if ob.type == 'MESH' or ob.type == 'CURVE': + if ob.type == 'CURVE': + mesh = ob.to_mesh(depsgraph=bpy.context.depsgraph, apply_modifiers=True, calc_undeformed=False) + else: + mesh = ob.data + fco = len(mesh.polygons) + fc += fco + vc += len(mesh.vertices) + fcor = fco + for f in mesh.polygons: + # face sides counter + if len(f.vertices) == 3: + tris += 1 + elif len(f.vertices) == 4: + quads += 1 + elif len(f.vertices) > 4: + ngons += 1 + + # manifold counter + for i, v in enumerate(f.vertices): + v1 = f.vertices[i - 1] + e = (min(v, v1), max(v, v1)) + edges_counts[e] = edges_counts.get(e, 0) + 1 + + # all meshes have to be manifold for this to work. + manifold = manifold and not any(i in edges_counts.values() for i in [0, 1, 3, 4]) + + for m in ob.modifiers: + if m.type == 'SUBSURF' or m.type == 'MULTIRES': + fcor *= 4 ** m.render_levels + if m.type == 'SOLIDIFY': # this is rough estimate, not to waste time with evaluating all nonmanifold edges + fcor *= 2 + if m.type == 'ARRAY': + fcor *= m.count + if m.type == 'MIRROR': + fcor *= 2 + if m.type == 'DECIMATE': + fcor *= m.ratio + fcr += fcor + + # write out props + props.face_count = fc + props.face_count_render = fcr + # print(tris, quads, ngons) + if quads > 0 and tris == 0 and ngons == 0: + props.mesh_poly_type = 'QUAD' + elif quads > tris and quads > ngons: + props.mesh_poly_type = 'QUAD_DOMINANT' + elif tris > quads and tris > quads: + props.mesh_poly_type = 'TRI_DOMINANT' + elif quads == 0 and tris > 0 and ngons == 0: + props.mesh_poly_type = 'TRI' + elif ngons > quads and ngons > tris: + props.mesh_poly_type = 'NGON' + else: + props.mesh_poly_type = 'OTHER' + + props.manifold = manifold + + +def countObs(props, obs): + ob_types = {} + count = len(obs) + for ob in obs: + otype = ob.type.lower() + ob_types[otype] = ob_types.get(otype, 0) + 1 + print(count, ob_types) + props.object_count = count + + +def check_modifiers(props, obs): + # modif_mapping = { + # } + modifiers = [] + for ob in obs: + for m in ob.modifiers: + mtype = m.type + mtype = mtype.replace('_', ' ') + mtype = mtype.lower() + # mtype = mtype.capitalize() + if mtype not in modifiers: + modifiers.append(mtype) + if m.type == 'SMOKE': + if m.smoke_type == 'FLOW': + smt = m.flow_settings.smoke_flow_type + if smt == 'BOTH' or smt == 'FIRE': + modifiers.append('fire') + print(mtype) + + # for mt in modifiers: + effectmodifiers = ['soft body', 'fluid simulation', 'particle system', 'collision', 'smoke', 'cloth', + 'dynamic paint'] + for m in modifiers: + if m in effectmodifiers: + props.simulation = True + if ob.rigid_body is not None: + props.simulation = True + modifiers.append('rigid body') + finalstr = '' + for m in modifiers: + finalstr += m + finalstr += ',' + props.modifiers = finalstr + + +def get_autotags(): + """ call all analysis functions """ + ui = bpy.context.scene.blenderkitUI + if ui.asset_type =='MODEL': + ob = utils.get_active_model() + obs = utils.get_hierarchy(ob) + props = ob.blenderkit + if props.name == "": + props.name = ob.name + + # reset some properties here, because they might not get re-filled at all when they aren't needed anymore. + props.texture_resolution_max = 0 + props.texture_resolution_min = 0 + # disabled printing checking, some 3d print addon bug. + # check_printable( props, obs) + check_render_engine(props, obs) + + dim, bbox_min, bbox_max = utils.get_dimensions(obs) + props.dimensions = dim + props.bbox_min = bbox_min + props.bbox_max = bbox_max + + check_rig(props, obs) + check_anim(props, obs) + check_meshprops(props, obs) + check_modifiers(props, obs) + countObs(props, obs) + elif ui.asset_type =='MATERIAL': + # reset some properties here, because they might not get re-filled at all when they aren't needed anymore. + + mat = utils.get_active_asset() + props = mat.blenderkit + props.texture_resolution_max = 0 + props.texture_resolution_min = 0 + check_material(props, mat) + + +class AutoFillTags(bpy.types.Operator): + """Fill tags for asset. Now run before upload, no need to interact from user side.""" + bl_idname = "object.blenderkit_auto_tags" + bl_label = "Generate Auto Tags for BlenderKit" + + @classmethod + def poll(cls, context): + return context.active_object is not None + + def execute(self, context): + get_autotags() + return {'FINISHED'} + + +def register_asset_inspector(): + bpy.utils.register_class(AutoFillTags) + + +def unregister_asset_inspector(): + bpy.utils.unregister_class(AutoFillTags) + + +if __name__ == "__main__": + register() diff --git a/blenderkit/autothumb.py b/blenderkit/autothumb.py new file mode 100644 index 00000000..00445629 --- /dev/null +++ b/blenderkit/autothumb.py @@ -0,0 +1,329 @@ +# ##### 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 importlib + +if "bpy" in locals(): + from importlib import reload + + if "paths" in locals(): + reload(paths) + if "utils" in locals(): + reload(utils) + if "bg_blender" in locals(): + reload(bg_blender) +else: + from blenderkit import paths, utils, bg_blender + +import tempfile, os, subprocess, json, sys + +import bpy + +BLENDERKIT_EXPORT_DATA_FILE = "data.json" + +ABOVE_NORMAL_PRIORITY_CLASS = 0x00008000 +BELOW_NORMAL_PRIORITY_CLASS = 0x00004000 +HIGH_PRIORITY_CLASS = 0x00000080 +IDLE_PRIORITY_CLASS = 0x00000040 +NORMAL_PRIORITY_CLASS = 0x00000020 +REALTIME_PRIORITY_CLASS = 0x00000100 + + +def check_thumbnail(props, imgpath): + img = utils.get_hidden_image(imgpath, 'upload_preview', force_reload=True) + if img is not None: # and img.size[0] == img.size[1] and img.size[0] >= 512 and ( + # img.file_format == 'JPEG' or img.file_format == 'PNG'): + props.has_thumbnail = True + props.thumbnail_generating_state = '' + return + else: + props.has_thumbnail = False + output = '' + if img is None or img.size[0] == 0 or img.filepath.find('thumbnail_notready.jpg') > -1: + output += 'No thumbnail or wrong file path\n' + else: + pass; + # this is causing problems on some platforms, don't know why.. + # if img.size[0] != img.size[1]: + # output += 'image not a square\n' + # if img.size[0] < 512: + # output += 'image too small, should be at least 512x512\n' + # if img.file_format != 'JPEG' or img.file_format != 'PNG': + # output += 'image has to be a jpeg or png' + props.thumbnail_generating_state = output + + +def update_upload_model_preview(self, context): + ob = utils.get_active_model() + if ob is not None: + props = ob.blenderkit + imgpath = props.thumbnail + check_thumbnail(props, imgpath) + + +def update_upload_scene_preview(self, context): + s = bpy.context.scene + props = s.blenderkit + imgpath = props.thumbnail + check_thumbnail(props, imgpath) + + +def update_upload_material_preview(self, context): + if hasattr(bpy.context, 'active_object') \ + and bpy.context.active_object is not None \ + and bpy.context.active_object.active_material is not None: + mat = bpy.context.active_object.active_material + props = mat.blenderkit + imgpath = props.thumbnail + check_thumbnail(props, imgpath) + + +def update_upload_brush_preview(self, context): + brush = utils.get_active_brush() + if brush is not None: + props = brush.blenderkit + imgpath = bpy.path.abspath(brush.icon_filepath) + check_thumbnail(props, imgpath) + + +def start_thumbnailer(self, context): + # Prepare to save the file + mainmodel = utils.get_active_model() + mainmodel.blenderkit.is_generating_thumbnail = True + mainmodel.blenderkit.thumbnail_generating_state = 'starting blender instance' + + binary_path = bpy.app.binary_path + script_path = os.path.dirname(os.path.realpath(__file__)) + basename, ext = os.path.splitext(bpy.data.filepath) + if not basename: + basename = os.path.join(basename, "temp") + if not ext: + ext = ".blend" + asset_name = mainmodel.blenderkit.name + tempdir = tempfile.mkdtemp() + + file_dir = os.path.dirname(bpy.data.filepath) + thumb_path = os.path.join(file_dir, asset_name) + rel_thumb_path = os.path.join('//', asset_name) + + i = 0 + while os.path.isfile(thumb_path + '.jpg'): + thumb_path = os.path.join(file_dir, asset_name + '_' + str(i).zfill(4)) + rel_thumb_path = os.path.join('//', asset_name + '_' + str(i).zfill(4)) + i += 1 + + filepath = os.path.join(tempdir, "thumbnailer_blenderkit" + ext) + tfpath = paths.get_thumbnailer_filepath() + datafile = os.path.join(tempdir, BLENDERKIT_EXPORT_DATA_FILE) + try: + # save a copy of actual scene but don't interfere with the users models + bpy.ops.wm.save_as_mainfile(filepath=filepath, compress=False, copy=True) + + obs = utils.get_hierarchy(mainmodel) + obnames = [] + for ob in obs: + obnames.append(ob.name) + with open(datafile, 'w') as s: + bkit = mainmodel.blenderkit + json.dump({ + "type": "model", + "models": str(obnames), + "thumbnail_angle": bkit.thumbnail_angle, + "thumbnail_snap_to": bkit.thumbnail_snap_to, + "thumbnail_background_lightness": bkit.thumbnail_background_lightness, + "thumbnail_samples": bkit.thumbnail_samples, + "thumbnail_denoising": bkit.thumbnail_denoising, + }, s) + + flags = BELOW_NORMAL_PRIORITY_CLASS + if sys.platform != 'win32': # TODO test this on windows and find out how to change process priority on linux + # without psutil - we don't want any more libs in the addon + flags = 0 + + proc = subprocess.Popen([ + binary_path, + "--background", + "-noaudio", + tfpath, + "--python", os.path.join(script_path, "autothumb_model_bg.py"), + "--", datafile, filepath, thumb_path, tempdir + ], bufsize=1, stdout=subprocess.PIPE, stdin=subprocess.PIPE, creationflags=flags) + + eval_path_computing = "bpy.data.objects['%s'].blenderkit.is_generating_thumbnail" % mainmodel.name + eval_path_state = "bpy.data.objects['%s'].blenderkit.thumbnail_generating_state" % mainmodel.name + eval_path = "bpy.data.objects['%s']" % mainmodel.name + + bg_blender.add_bg_process(eval_path_computing=eval_path_computing, eval_path_state=eval_path_state, + eval_path=eval_path, process_type='THUMBNAILER', process=proc) + + mainmodel.blenderkit.thumbnail = rel_thumb_path + '.jpg' + mainmodel.blenderkit.thumbnail_generating_state = 'Saving .blend file' + + except Exception as e: + self.report({'WARNING'}, "Error while exporting file: %s" % str(e)) + return {'FINISHED'} + + +def start_material_thumbnailer(self, context): + # Prepare to save the file + mat = bpy.context.active_object.active_material + mat.blenderkit.is_generating_thumbnail = True + mat.blenderkit.thumbnail_generating_state = 'starting blender instance' + + binary_path = bpy.app.binary_path + script_path = os.path.dirname(os.path.realpath(__file__)) + basename, ext = os.path.splitext(bpy.data.filepath) + if not basename: + basename = os.path.join(basename, "temp") + if not ext: + ext = ".blend" + asset_name = mat.name + tempdir = tempfile.mkdtemp() + + file_dir = os.path.dirname(bpy.data.filepath) + + thumb_path = os.path.join(file_dir, asset_name) + rel_thumb_path = os.path.join('//', mat.name) + i = 0 + while os.path.isfile(thumb_path + '.png'): + thumb_path = os.path.join(file_dir, mat.name + '_' + str(i).zfill(4)) + rel_thumb_path = os.path.join('//', mat.name + '_' + str(i).zfill(4)) + i += 1 + + filepath = os.path.join(tempdir, "material_thumbnailer_cycles" + ext) + tfpath = paths.get_material_thumbnailer_filepath() + datafile = os.path.join(tempdir, BLENDERKIT_EXPORT_DATA_FILE) + try: + # save a copy of actual scene but don't interfere with the users models + bpy.ops.wm.save_as_mainfile(filepath=filepath, compress=False, copy=True) + + with open(datafile, 'w') as s: + bkit = mat.blenderkit + json.dump({ + "type": "material", + "material": mat.name, + "thumbnail_type": bkit.thumbnail_generator_type, + "thumbnail_scale": bkit.thumbnail_scale, + "thumbnail_background": bkit.thumbnail_background, + "thumbnail_background_lightness": bkit.thumbnail_background_lightness, + "thumbnail_samples": bkit.thumbnail_samples, + "thumbnail_denoising": bkit.thumbnail_denoising, + "adaptive_subdivision": bkit.adaptive_subdivision, + "texture_size_meters": bkit.texture_size_meters, + }, s) + + flags = BELOW_NORMAL_PRIORITY_CLASS + if sys.platform != 'win32': # TODO test this on windows + flags = 0 + + proc = subprocess.Popen([ + binary_path, + "--background", + "-noaudio", + tfpath, + "--python", os.path.join(script_path, "autothumb_material_bg.py"), + "--", datafile, filepath, thumb_path, tempdir + ], bufsize=1, stdout=subprocess.PIPE, stdin=subprocess.PIPE, creationflags=flags) + + eval_path_computing = "bpy.data.materials['%s'].blenderkit.is_generating_thumbnail" % mat.name + eval_path_state = "bpy.data.materials['%s'].blenderkit.thumbnail_generating_state" % mat.name + eval_path = "bpy.data.materials['%s']" % mat.name + + bg_blender.add_bg_process(eval_path_computing=eval_path_computing, eval_path_state=eval_path_state, + eval_path=eval_path, process_type='THUMBNAILER', process=proc) + + mat.blenderkit.thumbnail = rel_thumb_path + '.png' + mat.blenderkit.thumbnail_generating_state = 'Saving .blend file' + except Exception as e: + self.report({'WARNING'}, "Error while packing file: %s" % str(e)) + return {'FINISHED'} + + +class GenerateThumbnailOperator(bpy.types.Operator): + """Tooltip""" + bl_idname = "object.blenderkit_generate_thumbnail" + bl_label = "BlenderKit Thumbnail Generator" + + @classmethod + def poll(cls, context): + return bpy.context.active_object is not None + + def draw(self, context): + ob = bpy.context.active_object + while ob.parent is not None: + ob = ob.parent + props = ob.blenderkit + layout = self.layout + layout.label(text='thumbnailer settings') + layout.prop(props, 'thumbnail_background_lightness') + layout.prop(props, 'thumbnail_angle') + layout.prop(props, 'thumbnail_snap_to') + layout.prop(props, 'thumbnail_samples') + layout.prop(props, 'thumbnail_denoising') + + def execute(self, context): + start_thumbnailer(self, context) + return {'FINISHED'} + + def invoke(self, context, event): + wm = context.window_manager + return wm.invoke_props_dialog(self) + + +class GenerateMaterialThumbnailOperator(bpy.types.Operator): + """Tooltip""" + bl_idname = "object.blenderkit_material_thumbnail" + bl_label = "BlenderKit Material Thumbnail Generator" + + @classmethod + def poll(cls, context): + return bpy.context.active_object is not None + + def check(self, context): + return True + + def draw(self, context): + layout = self.layout + props = bpy.context.active_object.active_material.blenderkit + layout.prop(props, 'thumbnail_generator_type') + layout.prop(props, 'thumbnail_scale') + layout.prop(props, 'thumbnail_background') + if props.thumbnail_background: + layout.prop(props, 'thumbnail_background_lightness') + layout.prop(props, 'thumbnail_samples') + layout.prop(props, 'thumbnail_denoising') + layout.prop(props, 'adaptive_subdivision') + + def execute(self, context): + start_material_thumbnailer(self, context) + + return {'FINISHED'} + + def invoke(self, context, event): + wm = context.window_manager + return wm.invoke_props_dialog(self) + + +def register_thumbnailer(): + bpy.utils.register_class(GenerateThumbnailOperator) + bpy.utils.register_class(GenerateMaterialThumbnailOperator) + + +def unregister_thumbnailer(): + bpy.utils.unregister_class(GenerateThumbnailOperator) + bpy.utils.unregister_class(GenerateMaterialThumbnailOperator) diff --git a/blenderkit/autothumb_material_bg.py b/blenderkit/autothumb_material_bg.py new file mode 100644 index 00000000..824a0190 --- /dev/null +++ b/blenderkit/autothumb_material_bg.py @@ -0,0 +1,121 @@ +# ##### 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 sys, json, math +import bpy +from pathlib import Path +from blenderkit import utils, append_link, bg_blender + +BLENDERKIT_EXPORT_TEMP_DIR = sys.argv[-1] +BLENDERKIT_THUMBNAIL_PATH = sys.argv[-2] +BLENDERKIT_EXPORT_FILE_INPUT = sys.argv[-3] +BLENDERKIT_EXPORT_DATA = sys.argv[-4] + + +def render_thumbnails(): + bpy.ops.render.render(write_still=True, animation=False) + + +def unhide_collection(cname): + collection = bpy.context.scene.collection.children[cname] + collection.hide_viewport = False + collection.hide_render = False + collection.hide_select = False + + +if __name__ == "__main__": + try: + bg_blender.progress('preparing thumbnail scene') + with open(BLENDERKIT_EXPORT_DATA, 'r') as s: + data = json.load(s) + # append_material(file_name, matname = None, link = False, fake_user = True) + mat = append_link.append_material(file_name=BLENDERKIT_EXPORT_FILE_INPUT, matname=data["material"], link=True, + fake_user=False) + + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + + s = bpy.context.scene + + colmapdict = { + 'BALL': 'Ball', + 'CUBE': 'Cube', + 'FLUID': 'Fluid', + 'CLOTH': 'Cloth', + 'HAIR': 'Hair' + } + + unhide_collection(colmapdict[data["thumbnail_type"]]) + if data['thumbnail_background']: + unhide_collection('Collection 13') + bpy.data.materials["bg checker colorable"].node_tree.nodes['input_level'].outputs['Value'].default_value \ + = data['thumbnail_background_lightness'] + tscale = data["thumbnail_scale"] + bpy.context.view_layer.objects['scaler'].scale = (tscale, tscale, tscale) + s.update() + for ob in bpy.context.visible_objects: + if ob.name[:15] == 'MaterialPreview': + ob.material_slots[0].material = mat + ob.data.texspace_size.x = 1 / tscale + ob.data.texspace_size.y = 1 / tscale + ob.data.texspace_size.z = 1 / tscale + if data["adaptive_subdivision"] == True: + ob.cycles.use_adaptive_subdivision = True + + else: + ob.cycles.use_adaptive_subdivision = False + ts = data['texture_size_meters'] + # if data["thumbnail_type"] in ['BALL', 'CUBE']: + # utils.automap(ob.name, tex_size = ts / tscale, bg_exception=True) + s.update() + + s.cycles.volume_step_size = tscale * .1 + + if user_preferences.thumbnail_use_gpu: + bpy.context.scene.cycles.device = 'GPU' + + s.cycles.samples = data['thumbnail_samples'] + bpy.context.view_layer.cycles.use_denoising = data['thumbnail_denoising'] + + # import blender's HDR here + hdr_path = Path('datafiles/studiolights/world/interior.exr') + bpath = Path(bpy.utils.resource_path('LOCAL')) + ipath = bpath / hdr_path + ipath = str(ipath) + + # this stuff is for mac and possibly linux. For blender // means relative path. + # for Mac, // means start of absolute path + if ipath.startswith('//'): + ipath = ipath[1:] + + img = bpy.data.images['interior.exr'] + img.filepath = ipath + img.reload() + + bpy.context.scene.render.filepath = BLENDERKIT_THUMBNAIL_PATH + bg_blender.progress('rendering thumbnail') + render_thumbnails() + bg_blender.progress('background autothumbnailer finished successfully') + + + except Exception as e: + print(e) + import traceback + + traceback.print_exc() + + sys.exit(1) diff --git a/blenderkit/autothumb_model_bg.py b/blenderkit/autothumb_model_bg.py new file mode 100644 index 00000000..dc34d59c --- /dev/null +++ b/blenderkit/autothumb_model_bg.py @@ -0,0 +1,158 @@ +# ##### 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 sys, json, math +from pathlib import Path +import bpy +from blenderkit import utils, append_link, bg_blender +import mathutils + +BLENDERKIT_EXPORT_TEMP_DIR = sys.argv[-1] +BLENDERKIT_THUMBNAIL_PATH = sys.argv[-2] +BLENDERKIT_EXPORT_FILE_INPUT = sys.argv[-3] +BLENDERKIT_EXPORT_DATA = sys.argv[-4] + + +def get_obnames(): + with open(BLENDERKIT_EXPORT_DATA, 'r') as s: + data = json.load(s) + obnames = eval(data['models']) + return obnames + + +def center_obs_for_thumbnail(obs): + s = bpy.context.scene + # obs = bpy.context.selected_objects + parent = obs[0] + + while parent.parent != None: + parent = parent.parent + # reset parent rotation, so we see how it really snaps. + parent.rotation_euler = (0, 0, 0) + bpy.context.scene.update() + minx, miny, minz, maxx, maxy, maxz = utils.get_bounds_worldspace(obs) + + cx = (maxx - minx) / 2 + minx + cy = (maxy - miny) / 2 + miny + for ob in s.collection.objects: + ob.select_set(False) + + bpy.context.view_layer.objects.active = parent + parent.location += mathutils.Vector((-cx, -cy, -minz)) + + camZ = s.camera.parent.parent + camZ.location.z = (maxz - minz) / 2 + dx = (maxx - minx) + dy = (maxy - miny) + dz = (maxz - minz) + r = math.sqrt(dx * dx + dy * dy + dz * dz) + + scaler = bpy.context.view_layer.objects['scaler'] + scaler.scale = (r, r, r) + coef = .7 + r *= coef + camZ.scale = (r, r, r) + s.update() + + +def render_thumbnails(): + bpy.ops.render.render(write_still=True, animation=False) + + +if __name__ == "__main__": + try: + with open(BLENDERKIT_EXPORT_DATA, 'r') as s: + data = json.load(s) + + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + + bg_blender.progress('preparing thumbnail scene') + obnames = get_obnames() + main_object, allobs = append_link.append_objects(file_name=BLENDERKIT_EXPORT_FILE_INPUT, + obnames=obnames, + link=True) + bpy.context.scene.update() + + camdict = { + 'GROUND': 'camera ground', + 'WALL': 'camera wall', + 'CEILING': 'camera ceiling', + 'FLOAT': 'camera float' + } + + bpy.context.scene.camera = bpy.data.objects[camdict[data['thumbnail_snap_to']]] + center_obs_for_thumbnail(allobs) + bpy.context.scene.render.filepath = BLENDERKIT_THUMBNAIL_PATH + if user_preferences.thumbnail_use_gpu: + bpy.context.scene.cycles.device = 'GPU' + + fdict = { + 'DEFAULT': 1, + 'FRONT': 2, + 'SIDE': 3, + 'TOP': 4, + } + s = bpy.context.scene + s.frame_set(fdict[data['thumbnail_angle']]) + + snapdict = { + 'GROUND': 'Ground', + 'WALL': 'Wall', + 'CEILING': 'Ceiling', + 'FLOAT': 'Float' + } + + collection = bpy.context.scene.collection.children[snapdict[data['thumbnail_snap_to']]] + collection.hide_viewport = False + collection.hide_render = False + collection.hide_select = False + + main_object.rotation_euler = (0, 0, 0) + bpy.data.materials['bkit background'].node_tree.nodes['Value'].outputs['Value'].default_value \ + = data['thumbnail_background_lightness'] + s.cycles.samples = data['thumbnail_samples'] + bpy.context.view_layer.cycles.use_denoising = data['thumbnail_denoising'] + bpy.context.scene.update() + + # import blender's HDR here + hdr_path = Path('datafiles/studiolights/world/interior.exr') + bpath = Path(bpy.utils.resource_path('LOCAL')) + ipath = bpath / hdr_path + ipath = str(ipath) + + # this stuff is for mac and possibly linux. For blender // means relative path. + # for Mac, // means start of absolute path + if ipath.startswith('//'): + ipath = ipath[1:] + + img = bpy.data.images['interior.exr'] + img.filepath = ipath + img.reload() + + bg_blender.progress('rendering thumbnail') + render_thumbnails() + fpath = BLENDERKIT_THUMBNAIL_PATH + '0001.jpg' + bg_blender.progress('background autothumbnailer finished successfully') + + + except: + import traceback + + traceback.print_exc() + sys.exit(1) diff --git a/blenderkit/bg_blender.py b/blenderkit/bg_blender.py new file mode 100644 index 00000000..e594fa9a --- /dev/null +++ b/blenderkit/bg_blender.py @@ -0,0 +1,237 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + + +import bpy +import sys, threading, os +import re + +from bpy.props import ( + EnumProperty, +) + +from blenderkit import utils + +bg_processes = [] + + +class threadCom: # object passed to threads to read background process stdout info + ''' Object to pass data between thread and ''' + + def __init__(self, eval_path_computing, eval_path_state, eval_path, process_type, proc, location=None, name=''): + # self.obname=ob.name + self.name = name + self.eval_path_computing = eval_path_computing # property that gets written to. + self.eval_path_state = eval_path_state # property that gets written to. + self.eval_path = eval_path # property that gets written to. + self.process_type = process_type + self.outtext = '' + self.proc = proc + self.lasttext = '' + self.message = '' # the message to be sent. + self.progress = 0.0 + self.location = location + self.error = False + self.log = '' + + +def threadread(tcom): + '''reads stdout of background process, done this way to have it non-blocking. this threads basically waits for a stdout line to come in, fills the data, dies.''' + found = False + while not found: + inline = tcom.proc.stdout.readline() + # print('readthread', time.time()) + inline = str(inline) + s = inline.find('progress{') + if s > -1: + e = inline.find('}') + tcom.outtext = inline[s + 9:e] + found = True + if tcom.outtext.find('%') > -1: + tcom.progress = float(re.findall('\d+\.\d+|\d+', tcom.outtext)[0]) + return + if s == -1: + s = inline.find('Remaining') + if s > -1: + # e=inline.find('}') + tcom.outtext = inline[s: s + 18] + found = True + return + if len(inline) > 3: + print(inline, len(inline)) + # if inline.find('Error'): + # tcom.error = True + # tcom.outtext = inline[2:] + + +def progress(text, n=None): + '''function for reporting during the script, works for background operations in the header.''' + # for i in range(n+1): + # sys.stdout.flush() + text = str(text) + if n is None: + n = '' + else: + n = ' ' + ' ' + str(int(n * 1000) / 1000) + '% ' + spaces = ' ' * (len(text) + 55) + sys.stdout.write('progress{%s%s}\n' % (text, n)) + sys.stdout.flush() + + +@bpy.app.handlers.persistent +def bg_update(): + '''monitoring of background process''' + text = '' + s = bpy.context.scene + + global bg_processes + if len(bg_processes) == 0: + return 2 + + for p in bg_processes: + # proc=p[1].proc + readthread = p[0] + tcom = p[1] + if not readthread.is_alive(): + readthread.join() + # readthread. + if tcom.error: + estring = tcom.eval_path_computing + ' = False' + exec (estring) + + tcom.lasttext = tcom.outtext + if tcom.outtext != '': + tcom.outtext = '' + estring = tcom.eval_path_state + ' = tcom.lasttext' + + exec (estring) + # print(tcom.lasttext) + if 'finished successfully' in tcom.lasttext: + bg_processes.remove(p) + estring = tcom.eval_path_computing + ' = False' + exec (estring) + else: + readthread = threading.Thread(target=threadread, args=([tcom]), daemon=True) + readthread.start() + p[0] = readthread + if len(bg_processes) == 0: + bpy.app.timers.unregister(bg_update) + + return .1 + + +process_types = ( + ('UPLOAD', 'Upload', ''), + ('THUMBNAILER', 'Thumbnailer', ''), +) + +process_sources = ( + ('MODEL', 'Model', 'set of objects'), + ('SCENE', 'Scene', 'set of scenes'), + ('MATERIAL', 'Material', 'any .blend Material'), + ('TEXTURE', 'Texture', 'a texture, or texture set'), + ('BRUSH', 'Brush', 'brush, can be any type of blender brush'), +) + + +class KillBgProcess(bpy.types.Operator): + '''Remove processes in background.''' + bl_idname = "object.kill_bg_process" + bl_label = "Kill Background Process" + bl_options = {'REGISTER', 'UNDO'} + + process_type: EnumProperty( + name="Type", + items=process_types, + description="Type of process", + default="UPLOAD", + ) + + process_source: EnumProperty( + name="Source", + items=process_sources, + description="Source of process", + default="MODEL", + ) + + def execute(self, context): + s = bpy.context.scene + + cls = bpy.ops.object.convert.__class__ + # first do the easy stuff...TODO all cases. + props = utils.get_upload_props() + if self.process_type == 'UPLOAD': + props.uploading = False + if self.process_type == 'THUMBNAILER': + props.is_generating_thumbnail = False + global blenderkit_bg_process + print('killing', self.process_source, self.process_type) + # then go kill the process. this wasn't working for unsetting props and that was the reason for changing to the method above. + + processes = bg_processes + for p in processes: + + tcom = p[1] + print(tcom.process_type, self.process_type) + if tcom.process_type == self.process_type: + source = eval(tcom.eval_path) + print(source.bl_rna.name, self.process_source) + print(source.name) + kill = False + if source.bl_rna.name == 'Object' and self.process_source == 'MODEL': + if source.name == bpy.context.active_object.name: + kill = True + if source.bl_rna.name == 'Material' and self.process_source == 'MATERIAL': + if source.name == bpy.context.active_object.active_material.name: + kill = True + if source.bl_rna.name == 'Brush' and self.process_source == 'BRUSH': + brush = utils.get_active_brush() + if brush is not None and source.name == brush.name: + kill = True + if kill: + estring = tcom.eval_path_computing + ' = False' + exec (estring) + processes.remove(p) + tcom.proc.kill() + + return {'FINISHED'} + + +def add_bg_process(location=None, name=None, eval_path_computing='', eval_path_state='', eval_path='', process_type='', + process=None): + '''adds process for monitoring''' + global bg_processes + tcom = threadCom(eval_path_computing, eval_path_state, eval_path, process_type, process, location, name) + readthread = threading.Thread(target=threadread, args=([tcom]), daemon=True) + readthread.start() + + bg_processes.append([readthread, tcom]) + if not bpy.app.timers.is_registered(bg_update): + bpy.app.timers.register(bg_update, persistent=True) + + +def stert_bg_blender(): + pass; + + +def register(): + bpy.utils.register_class(KillBgProcess) + + +def unregister(): + bpy.utils.unregister_class(KillBgProcess) diff --git a/blenderkit/blendfiles/cleaned.blend b/blenderkit/blendfiles/cleaned.blend Binary files differnew file mode 100644 index 00000000..4ad5f57c --- /dev/null +++ b/blenderkit/blendfiles/cleaned.blend diff --git a/blenderkit/blendfiles/material_thumbnailer_cycles.blend b/blenderkit/blendfiles/material_thumbnailer_cycles.blend Binary files differnew file mode 100644 index 00000000..4e73329e --- /dev/null +++ b/blenderkit/blendfiles/material_thumbnailer_cycles.blend diff --git a/blenderkit/blendfiles/thumbnailer.blend b/blenderkit/blendfiles/thumbnailer.blend Binary files differnew file mode 100644 index 00000000..76a7f2cd --- /dev/null +++ b/blenderkit/blendfiles/thumbnailer.blend diff --git a/blenderkit/categories.py b/blenderkit/categories.py new file mode 100644 index 00000000..e4ff9be1 --- /dev/null +++ b/blenderkit/categories.py @@ -0,0 +1,89 @@ +import requests +import json +import os +from blenderkit import paths +import shutil +import threading + + +def count_to_parent(parent): + for c in parent['children']: + count_to_parent(c) + parent['assetCount'] += c['assetCount'] + + +def fix_category_counts(categories): + for c in categories: + count_to_parent(c) + + +def filter_category(category): + if category['assetCount'] < 1: + return True + else: + to_remove = [] + for c in category['children']: + if filter_category(c): + to_remove.append(c) + for c in to_remove: + category['children'].remove(c) + + +def filter_categories(categories): + for category in categories: + filter_category(category) + + +def get_category(categories, cat_path=()): + for category in cat_path: + for c in categories: + if c['slug'] == category: + categories = c['children'] + if category == cat_path[-1]: + return (c) + break; + + +def copy_categories(): + # this creates the categories system on only + tempdir = paths.get_temp_dir() + categories_filepath = os.path.join(tempdir, 'categories.json') + if not os.path.exists(categories_filepath): + source_path = paths.get_addon_file(subpath='data' + os.sep + 'categories.json') + print('attempt to copy categories from: %s to %s' % (categories_filepath, source_path)) + try: + shutil.copy(source_path, categories_filepath) + except: + print("couldn't copy categories file") + + +def fetch_categories(API_key): + BLENDERKIT_API_MAIN = "https://www.blenderkit.com/api/v1/" + + url = paths.get_bkit_url() + 'categories/' + + headers = { + "accept": "application/json", + "Authorization": "Bearer %s" % API_key + } + tempdir = paths.get_temp_dir() + categories_filepath = os.path.join(tempdir, 'categories.json') + + try: + r = requests.get(url, headers=headers) + rdata = r.json() + categories = rdata['results'] + fix_category_counts(categories) + # filter_categories(categories) #TODO this should filter categories for search, but not for upload. by now off. + with open(categories_filepath, 'w') as s: + json.dump(categories, s, indent=4) + except: + print('category fetching failed') + if not os.path.exists(categories_filepath): + source_path = paths.get_addon_file(subpath='data' + os.sep + 'categories.json') + shutil.copy(source_path, categories_filepath) + + +def fetch_categories_thread(API_key): + cat_thread = threading.Thread(target=fetch_categories, args=([API_key]), daemon=True) + cat_thread.start() diff --git a/blenderkit/data/categories.json b/blenderkit/data/categories.json new file mode 100644 index 00000000..652f3934 --- /dev/null +++ b/blenderkit/data/categories.json @@ -0,0 +1,3145 @@ +[ + { + "alternateUrl": "", + "slug": "addon", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "addon", + "thumbnailHeight": null, + "name": "addon", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "anatomy-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 5, + "active": true, + "alternateTitle": "anatomy", + "thumbnailHeight": null, + "name": "anatomy", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "animal-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 4, + "active": true, + "alternateTitle": "animal", + "thumbnailHeight": null, + "name": "animal", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "art-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "art", + "thumbnailHeight": null, + "name": "art", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "clothing-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 8, + "active": true, + "alternateTitle": "clothing", + "thumbnailHeight": null, + "name": "clothing", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "crack", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "crack", + "thumbnailHeight": null, + "name": "crack", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "cut", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 2, + "active": true, + "alternateTitle": "cut", + "thumbnailHeight": null, + "name": "cut", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "damage", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 4, + "active": true, + "alternateTitle": "damage", + "thumbnailHeight": null, + "name": "damage", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "dirt-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "dirt", + "thumbnailHeight": null, + "name": "dirt", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "fabric-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 8, + "active": true, + "alternateTitle": "fabric", + "thumbnailHeight": null, + "name": "fabric", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "geometric", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 2, + "active": true, + "alternateTitle": "geometric", + "thumbnailHeight": null, + "name": "geometric", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "human-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 7, + "active": true, + "alternateTitle": "human", + "thumbnailHeight": null, + "name": "human", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "industrial-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 10, + "active": true, + "alternateTitle": "industrial", + "thumbnailHeight": null, + "name": "industrial", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "landscape-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 9, + "active": true, + "alternateTitle": "landscape", + "thumbnailHeight": null, + "name": "landscape", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "misc", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "misc", + "thumbnailHeight": null, + "name": "misc", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "nature-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "nature", + "thumbnailHeight": null, + "name": "nature", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "pattern", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 7, + "active": true, + "alternateTitle": "pattern", + "thumbnailHeight": null, + "name": "pattern", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "rock-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 7, + "active": true, + "alternateTitle": "rock", + "thumbnailHeight": null, + "name": "rock", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "rust-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "rust", + "thumbnailHeight": null, + "name": "rust", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "sculpture-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "sculpture", + "thumbnailHeight": null, + "name": "sculpture", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "stitches", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 12, + "active": true, + "alternateTitle": "stitches", + "thumbnailHeight": null, + "name": "stitches", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "stone-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "stone", + "thumbnailHeight": null, + "name": "stone", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "tree-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 4, + "active": true, + "alternateTitle": "tree", + "thumbnailHeight": null, + "name": "tree", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "wood-brush", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "wood", + "thumbnailHeight": null, + "name": "wood", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 95, + "active": true, + "alternateTitle": "brush", + "thumbnailHeight": null, + "name": "brush", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "material", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "animal-material", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "animal", + "thumbnailHeight": null, + "name": "animal", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "asphalt", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 5, + "active": true, + "alternateTitle": "asphalt", + "thumbnailHeight": null, + "name": "asphalt", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "bricks", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 5, + "active": true, + "alternateTitle": "bricks", + "thumbnailHeight": null, + "name": "bricks", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "ceramic", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "ceramic", + "thumbnailHeight": null, + "name": "ceramic", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "concrete", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 12, + "active": true, + "alternateTitle": "concrete", + "thumbnailHeight": null, + "name": "concrete", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "dirt", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 5, + "active": true, + "alternateTitle": "dirt", + "thumbnailHeight": null, + "name": "dirt", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "fabric", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 2, + "active": true, + "alternateTitle": "fabric", + "thumbnailHeight": null, + "name": "fabric", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "floor", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 15, + "active": true, + "alternateTitle": "floor", + "thumbnailHeight": null, + "name": "floor", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "food-material", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 2, + "active": true, + "alternateTitle": "food", + "thumbnailHeight": null, + "name": "food", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "fx", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "fx", + "thumbnailHeight": null, + "name": "fx", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "glass", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 2, + "active": true, + "alternateTitle": "glass", + "thumbnailHeight": null, + "name": "glass", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "grass", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "grass", + "thumbnailHeight": null, + "name": "grass", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "ground-material", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 6, + "active": true, + "alternateTitle": "ground", + "thumbnailHeight": null, + "name": "ground", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "human", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "human", + "thumbnailHeight": null, + "name": "human", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "ice", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "ice", + "thumbnailHeight": null, + "name": "ice", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "leather", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "leather", + "thumbnailHeight": null, + "name": "leather", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "liquid", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 4, + "active": true, + "alternateTitle": "liquid", + "thumbnailHeight": null, + "name": "liquid", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "marble", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "marble", + "thumbnailHeight": null, + "name": "marble", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "metal", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 16, + "active": true, + "alternateTitle": "metal", + "thumbnailHeight": null, + "name": "metal", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "organic", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "organic", + "thumbnailHeight": null, + "name": "organic", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "ornaments", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "ornaments", + "thumbnailHeight": null, + "name": "ornaments", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "paper", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "paper", + "thumbnailHeight": null, + "name": "paper", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "paving", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "paving", + "thumbnailHeight": null, + "name": "paving", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "plaster", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 4, + "active": true, + "alternateTitle": "plaster", + "thumbnailHeight": null, + "name": "plaster", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "plastic", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "plastic", + "thumbnailHeight": null, + "name": "plastic", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "rock", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 5, + "active": true, + "alternateTitle": "rock", + "thumbnailHeight": null, + "name": "rock", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "roofing", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "roofing", + "thumbnailHeight": null, + "name": "roofing", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "rubber", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "rubber", + "thumbnailHeight": null, + "name": "rubber", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "rust", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 4, + "active": true, + "alternateTitle": "rust", + "thumbnailHeight": null, + "name": "rust", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "sand", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 2, + "active": true, + "alternateTitle": "sand", + "thumbnailHeight": null, + "name": "sand", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "soil", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "soil", + "thumbnailHeight": null, + "name": "soil", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "stone", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 8, + "active": true, + "alternateTitle": "stone", + "thumbnailHeight": null, + "name": "stone", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "tech", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "tech", + "thumbnailHeight": null, + "name": "tech", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "tiles", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 10, + "active": true, + "alternateTitle": "tiles", + "thumbnailHeight": null, + "name": "tiles", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "wood", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 8, + "active": true, + "alternateTitle": "wood", + "thumbnailHeight": null, + "name": "wood", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 127, + "active": true, + "alternateTitle": "materials", + "thumbnailHeight": null, + "name": "material", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "model", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "aircraft", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "commercial", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "commercial", + "thumbnailHeight": null, + "name": "commercial", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "helicopter", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "helicopter", + "thumbnailHeight": null, + "name": "helicopter", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "historic-aircraft", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "historic", + "thumbnailHeight": null, + "name": "historic", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "jet", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "jet", + "thumbnailHeight": null, + "name": "jet", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "part-aircraft", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "part", + "thumbnailHeight": null, + "name": "part", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "private", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "private", + "thumbnailHeight": null, + "name": "private", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "aircraft", + "thumbnailHeight": null, + "name": "aircraft", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "art", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "design", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "design", + "thumbnailHeight": null, + "name": "design", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "drawing", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "drawing", + "thumbnailHeight": null, + "name": "drawing", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "literature", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 10, + "active": true, + "alternateTitle": "literature", + "thumbnailHeight": null, + "name": "literature", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "painting", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "painting", + "thumbnailHeight": null, + "name": "painting", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "sculpture", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "sculpture", + "thumbnailHeight": null, + "name": "sculpture", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "supplies", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "supplies", + "thumbnailHeight": null, + "name": "supplies", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 13, + "active": true, + "alternateTitle": "art", + "thumbnailHeight": null, + "name": "art", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "character", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "anatomy", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "anatomy", + "thumbnailHeight": null, + "name": "anatomy", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "child", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "child", + "thumbnailHeight": null, + "name": "child", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "clothing", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 4, + "active": true, + "alternateTitle": "clothing", + "thumbnailHeight": null, + "name": "clothing", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "fantasy", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "fantasy", + "thumbnailHeight": null, + "name": "fantasy", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "man", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 6, + "active": true, + "alternateTitle": "man", + "thumbnailHeight": null, + "name": "man", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "people", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "people", + "thumbnailHeight": null, + "name": "people", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "sci-fi-character", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "sci-fi", + "thumbnailHeight": null, + "name": "sci-fi", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "woman", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "woman", + "thumbnailHeight": null, + "name": "woman", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 14, + "active": true, + "alternateTitle": "character", + "thumbnailHeight": null, + "name": "character", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "exterior", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "building", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "building", + "thumbnailHeight": null, + "name": "building", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "cityspace", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "cityspace", + "thumbnailHeight": null, + "name": "cityspace", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "historic", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "historic", + "thumbnailHeight": null, + "name": "historic", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "house", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "house", + "thumbnailHeight": null, + "name": "house", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "industrial-exterior", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "industrial", + "thumbnailHeight": null, + "name": "industrial", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "landmark", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "landmark", + "thumbnailHeight": null, + "name": "landmark", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "landscape", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 14, + "active": true, + "alternateTitle": "landscape", + "thumbnailHeight": null, + "name": "landscape", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "public", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "public", + "thumbnailHeight": null, + "name": "public", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "sci-fi", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "sci-fi", + "thumbnailHeight": null, + "name": "sci-fi", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "stadium", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "stadium", + "thumbnailHeight": null, + "name": "stadium", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "street", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "street", + "thumbnailHeight": null, + "name": "street", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 17, + "active": true, + "alternateTitle": "exterior", + "thumbnailHeight": null, + "name": "exterior", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "food-drink", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "container", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "container", + "thumbnailHeight": null, + "name": "container", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "drink", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 12, + "active": true, + "alternateTitle": "drink", + "thumbnailHeight": null, + "name": "drink", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "drugs", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "drugs", + "thumbnailHeight": null, + "name": "drugs", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "food", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 4, + "active": true, + "alternateTitle": "food", + "thumbnailHeight": null, + "name": "food", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 19, + "active": true, + "alternateTitle": "food & drink", + "thumbnailHeight": null, + "name": "food & drink", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "furniture", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "bed", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "bed", + "thumbnailHeight": null, + "name": "bed", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "carpet", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 6, + "active": true, + "alternateTitle": "carpet", + "thumbnailHeight": null, + "name": "carpet", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "desk", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "desk", + "thumbnailHeight": null, + "name": "desk", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "fireplace", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "fireplace", + "thumbnailHeight": null, + "name": "fireplace", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "lighting", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 10, + "active": true, + "alternateTitle": "lighting", + "thumbnailHeight": null, + "name": "lighting", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "seating", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 14, + "active": true, + "alternateTitle": "seating", + "thumbnailHeight": null, + "name": "seating", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "shelving", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 9, + "active": true, + "alternateTitle": "shelving", + "thumbnailHeight": null, + "name": "shelving", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "sofa", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 7, + "active": true, + "alternateTitle": "sofa", + "thumbnailHeight": null, + "name": "sofa", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "storage", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "storage", + "thumbnailHeight": null, + "name": "storage", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "table", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 9, + "active": true, + "alternateTitle": "table", + "thumbnailHeight": null, + "name": "table", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 56, + "active": true, + "alternateTitle": "furniture", + "thumbnailHeight": null, + "name": "furniture", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "industrial", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "agriculture", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "agriculture", + "thumbnailHeight": null, + "name": "agriculture", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "communication", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "communication", + "thumbnailHeight": null, + "name": "communication", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "construction", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "construction", + "thumbnailHeight": null, + "name": "construction", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "container-industrial", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "container", + "thumbnailHeight": null, + "name": "container", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "machine", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "machine", + "thumbnailHeight": null, + "name": "machine", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "tool", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "tool", + "thumbnailHeight": null, + "name": "tool", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "utility-industrial", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "part", + "thumbnailHeight": null, + "name": "utility", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 6, + "active": true, + "alternateTitle": "industrial", + "thumbnailHeight": null, + "name": "industrial", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "interior", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "bathroom", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "bathroom", + "thumbnailHeight": null, + "name": "bathroom", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "bedroom", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 7, + "active": true, + "alternateTitle": "bedroom", + "thumbnailHeight": null, + "name": "bedroom", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "decoration", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 38, + "active": true, + "alternateTitle": "decoration", + "thumbnailHeight": null, + "name": "decoration", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "hall", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "hall", + "thumbnailHeight": null, + "name": "hall", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "kids-room", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 4, + "active": true, + "alternateTitle": "kids room", + "thumbnailHeight": null, + "name": "kids room", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "kitchen", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 23, + "active": true, + "alternateTitle": "kitchen", + "thumbnailHeight": null, + "name": "kitchen", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "living-room", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "living room", + "thumbnailHeight": null, + "name": "living room", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "office", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 11, + "active": true, + "alternateTitle": "office", + "thumbnailHeight": null, + "name": "office", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "utility", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 18, + "active": true, + "alternateTitle": "part", + "thumbnailHeight": null, + "name": "utility", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 106, + "active": true, + "alternateTitle": "interior", + "thumbnailHeight": null, + "name": "interior", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "military", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "air", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "air", + "thumbnailHeight": null, + "name": "air", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "equipment", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "equipment", + "thumbnailHeight": null, + "name": "equipment", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "ground", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "ground", + "thumbnailHeight": null, + "name": "ground", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "historic-military", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "historic", + "thumbnailHeight": null, + "name": "historic", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "naval", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "naval", + "thumbnailHeight": null, + "name": "naval", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "weapon", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "weapon", + "thumbnailHeight": null, + "name": "weapon", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "military", + "thumbnailHeight": null, + "name": "military", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "music", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "accessories", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "accessories", + "thumbnailHeight": null, + "name": "accessories", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "instruments", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "instruments", + "thumbnailHeight": null, + "name": "instruments", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "stage", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "stage", + "thumbnailHeight": null, + "name": "stage", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "studio", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "studio", + "thumbnailHeight": null, + "name": "studio", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "music", + "thumbnailHeight": null, + "name": "music", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "nature", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "animal-nature", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "animal", + "thumbnailHeight": null, + "name": "animal", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "atmosphere", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "atmosphere", + "thumbnailHeight": null, + "name": "atmosphere", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "landscape-nature", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "landscape", + "thumbnailHeight": null, + "name": "landscape", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "plant", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "plant", + "thumbnailHeight": null, + "name": "plant", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "tree", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "tree", + "thumbnailHeight": null, + "name": "tree", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "weather", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "weather", + "thumbnailHeight": null, + "name": "weather", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "nature", + "thumbnailHeight": null, + "name": "nature", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "space", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "astronomy", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "astronomy", + "thumbnailHeight": null, + "name": "astronomy", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "planets", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "planets", + "thumbnailHeight": null, + "name": "planets", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "sci-fi-space", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "sci-fi", + "thumbnailHeight": null, + "name": "sci-fi", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "spacecraft", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "spacecraft", + "thumbnailHeight": null, + "name": "spacecraft", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "space", + "thumbnailHeight": null, + "name": "space", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "sports", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "animal", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "animal", + "thumbnailHeight": null, + "name": "animal", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "exercise", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "exercise", + "thumbnailHeight": null, + "name": "exercise", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "extreme", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "extreme", + "thumbnailHeight": null, + "name": "extreme", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "individual", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "individual", + "thumbnailHeight": null, + "name": "individual", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "outdoor", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "outdoor", + "thumbnailHeight": null, + "name": "outdoor", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "team", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "team", + "thumbnailHeight": null, + "name": "team", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "sports", + "thumbnailHeight": null, + "name": "sports", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "technology", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "ai", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "ai", + "thumbnailHeight": null, + "name": "ai", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "audio", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "audio", + "thumbnailHeight": null, + "name": "audio", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "computer", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "computer", + "thumbnailHeight": null, + "name": "computer", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "medical", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "medical", + "thumbnailHeight": null, + "name": "medical", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "phone", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 2, + "active": true, + "alternateTitle": "phone", + "thumbnailHeight": null, + "name": "phone", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "photography", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 1, + "active": true, + "alternateTitle": "photography", + "thumbnailHeight": null, + "name": "photography", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "science", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "science", + "thumbnailHeight": null, + "name": "science", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "video", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "video", + "thumbnailHeight": null, + "name": "video", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 3, + "active": true, + "alternateTitle": "technology", + "thumbnailHeight": null, + "name": "technology", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "vehicle", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "bicycle", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "bicycle", + "thumbnailHeight": null, + "name": "bicycle", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "bus", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "bus", + "thumbnailHeight": null, + "name": "bus", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "car", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "car", + "thumbnailHeight": null, + "name": "car", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "historic-vehicle", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "historic", + "thumbnailHeight": null, + "name": "historic", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "industrial-vehicle", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "industrial", + "thumbnailHeight": null, + "name": "industrial", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "motorcycle", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "motorcycle", + "thumbnailHeight": null, + "name": "motorcycle", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "part-vehicle", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "part", + "thumbnailHeight": null, + "name": "part", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "train", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "train", + "thumbnailHeight": null, + "name": "train", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "truck", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "truck", + "thumbnailHeight": null, + "name": "truck", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "vehicle", + "thumbnailHeight": null, + "name": "vehicle", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "watercraft", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "historic-watercraft", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "historic", + "thumbnailHeight": null, + "name": "historic", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "industrial-watercraft", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "industrial", + "thumbnailHeight": null, + "name": "industrial", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "part-watercraft", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "part", + "thumbnailHeight": null, + "name": "part", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "personal", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "personal", + "thumbnailHeight": null, + "name": "personal", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "recreational", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "recreational", + "thumbnailHeight": null, + "name": "recreational", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "watercraft", + "thumbnailHeight": null, + "name": "watercraft", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 245, + "active": true, + "alternateTitle": "model", + "thumbnailHeight": null, + "name": "model", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "scene", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "templates", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "brush-templates", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 6, + "active": true, + "alternateTitle": "brush templates", + "thumbnailHeight": null, + "name": "brush templates", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 6, + "active": true, + "alternateTitle": "templates", + "thumbnailHeight": null, + "name": "templates", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 12, + "active": true, + "alternateTitle": "scene", + "thumbnailHeight": null, + "name": "scene", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "texture", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "texture", + "thumbnailHeight": null, + "name": "texture", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "world", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "animals", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [ + { + "alternateUrl": "", + "slug": "mammals", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "Mammals", + "thumbnailHeight": null, + "name": "Mammals", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "Animals", + "thumbnailHeight": null, + "name": "Animals", + "thumbnail": null, + "description": "" + }, + { + "alternateUrl": "", + "slug": "plants", + "metaKeywords": "", + "thumbnailWidth": null, + "metaExtra": "", + "children": [], + "order": 0, + "assetCount": 0, + "active": true, + "alternateTitle": "Plants", + "thumbnailHeight": null, + "name": "Plants", + "thumbnail": null, + "description": "" + } + ], + "order": 0, + "assetCount": 7, + "active": true, + "alternateTitle": "World", + "thumbnailHeight": null, + "name": "World", + "thumbnail": null, + "description": "" + } +]
\ No newline at end of file diff --git a/blenderkit/download.py b/blenderkit/download.py new file mode 100644 index 00000000..cfe5d1f9 --- /dev/null +++ b/blenderkit/download.py @@ -0,0 +1,832 @@ +# ##### 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 threading +import time +import requests +import shutil, sys, os, math +import random +import uuid +import copy + +if "bpy" in locals(): + import imp + + imp.reload(paths) + imp.reload(append_link) + +else: + from blenderkit import paths, append_link, utils +import bpy +from bpy.props import ( + IntProperty, + FloatProperty, + FloatVectorProperty, + StringProperty, + EnumProperty, + BoolProperty, + PointerProperty, +) +from bpy.app.handlers import persistent + +download_threads = [] + + +def check_missing(): + '''checks for missing files, and possibly starts re-download of these into the scene''' + s = bpy.context.scene + # missing libs: + # TODO: put these into a panel and let the user decide if these should be downloaded. + missing = [] + for l in bpy.data.libraries: + fp = l.filepath + if fp.startswith('//'): + fp = bpy.path.abspath(fp) + if not os.path.exists(fp) and l.get('asset_data') is not None: + missing.append(l) + + print('missing libraries', missing) + + for l in missing: + asset_data = l['asset_data'] + downloaded = check_existing(asset_data) + if downloaded: + try: + l.reload() + except: + download(l['asset_data'], redownload=True) + else: + download(l['asset_data'], redownload=True) + + +def check_unused(): + '''find assets that have been deleted from scene but their library is still present.''' + + used_libs = [] + for ob in bpy.data.objects: + if ob.instance_collection is not None and ob.instance_collection.library is not None: + # used_libs[ob.instance_collection.name] = True + if ob.instance_collection.library not in used_libs: + used_libs.append(ob.instance_collection.library) + + for ps in ob.particle_systems: + set = ps.settings + if ps.settings.render_type == 'GROUP' \ + and ps.settings.instance_collection is not None \ + and ps.settings.instance_collection.library not in used_libs: + used_libs.append(ps.settings.instance_collection) + + for l in bpy.data.libraries: + if l not in used_libs: + print('attempt to remove this library: ', l.filepath) + # have to unlink all groups, since the file is a 'user' even if the groups aren't used at all... + for user_id in l.users_id: + if type(user_id) == bpy.types.Collection: + bpy.data.collections.remove(user_id) + l.user_clear() + + +@persistent +def scene_save(context): + ''' does cleanup of blenderkit props and sends a message to the server about assets used.''' + # TODO this can be optimized by merging these 2 functions, since both iterate over all objects. + check_unused() + report_usages() + + +@persistent +def scene_load(context): + '''restart broken downloads on scene load''' + t = time.time() + s = bpy.context.scene + global download_threads + download_threads = [] + + # commenting this out - old restore broken download on scene start. Might come back if downloads get recorded in scene + # reset_asset_ids = {} + # reset_obs = {} + # for ob in bpy.context.scene.collection.objects: + # if ob.name[:12] == 'downloading ': + # obn = ob.name + # + # asset_data = ob['asset_data'] + # + # # obn.replace('#', '') + # # if asset_data['id'] not in reset_asset_ids: + # + # if reset_obs.get(asset_data['id']) is None: + # reset_obs[asset_data['id']] = [obn] + # reset_asset_ids[asset_data['id']] = asset_data + # else: + # reset_obs[asset_data['id']].append(obn) + # for asset_id in reset_asset_ids: + # asset_data = reset_asset_ids[asset_id] + # done = False + # if check_existing(asset_data): + # for obname in reset_obs[asset_id]: + # downloader = s.collection.objects[obname] + # done = try_finished_append(asset_data, + # model_location=downloader.location, + # model_rotation=downloader.rotation_euler) + # + # if not done: + # downloading = check_downloading(asset_data) + # if not downloading: + # print('redownloading %s' % asset_data['name']) + # download(asset_data, downloaders=reset_obs[asset_id], delete=True) + + # check for group users that have been deleted, remove the groups /files from the file... + # TODO scenes fixing part... download the assets not present on drive, + # and erase from scene linked files that aren't used in the scene. + print('continue downlaods ', time.time() - t) + t = time.time() + check_missing() + print('missing check', time.time() - t) + + +def get_scene_id(): + '''gets scene id and possibly also generates a new one''' + bpy.context.scene['uuid'] = bpy.context.scene.get('uuid', str(uuid.uuid4())) + return bpy.context.scene['uuid'] + + +def report_usages(): + '''report the usage of assets to the server.''' + mt = time.time() + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + api_key = user_preferences.api_key + sid = get_scene_id() + headers = {"accept": "application/json", "Authorization": "Bearer %s" % api_key} + url = paths.get_bkit_url() + paths.BLENDERKIT_REPORT_URL + + assets = {} + asset_obs = [] + scene = bpy.context.scene + asset_usages = {} + + for ob in scene.collection.objects: + if ob.get('asset_data') != None: + asset_obs.append(ob) + + for ob in asset_obs: + asset_data = ob['asset_data'] + abid = asset_data['asset_base_id'] + + if assets.get(abid) is None: + asset_usages[abid] = {'count': 1} + assets[abid] = asset_data + else: + asset_usages[abid]['count'] += 1 + + # brushes + for b in bpy.data.brushes: + if b.get('asset_data') != None: + abid = b['asset_data']['asset_base_id'] + asset_usages[abid] = {'count': 1} + assets[abid] = b['asset_data'] + # materials + for ob in scene.collection.objects: + for ms in ob.material_slots: + m = ms.material + + if m is not None and m.get('asset_data') is not None: + + abid = m['asset_data']['asset_base_id'] + if assets.get(abid) is None: + asset_usages[abid] = {'count': 1} + assets[abid] = m['asset_data'] + else: + asset_usages[abid]['count'] += 1 + + assets_list = [] + assets_reported = scene.get('assets reported', {}) + + new_assets_count = 0 + for k in asset_usages.keys(): + if k not in assets_reported.keys(): + data = asset_usages[k] + list_item = { + 'asset': k, + 'usageCount': data['count'], + 'proximitySet': data.get('proximity', []) + } + assets_list.append(list_item) + new_assets_count += 1 + if k not in assets_reported.keys(): + assets_reported[k] = True + + scene['assets reported'] = assets_reported + + if new_assets_count == 0: + print('no new assets were added') + return; + usage_report = { + 'scene': sid, + 'reportType': 'save', + 'assetusageSet': assets_list + } + + au = scene.get('assets used', {}) + ad = scene.get('assets deleted', {}) + + ak = assets.keys() + for k in au.keys(): + if k not in ak: + ad[k] = au[k] + else: + if k in ad: + ad.pop(k) + + # scene['assets used'] = {} + for k in ak: # rewrite assets used. + scene['assets used'][k] = assets[k] + + ###########check ratings herer too: + scene['assets rated'] = scene.get('assets rated', {}) + for k in assets.keys(): + scene['assets rated'][k] = scene['assets rated'].get(k, False) + thread = threading.Thread(target=utils.requests_post_thread, args=(url, usage_report, headers)) + thread.start() + # r = requests.post(url, headers=headers, json=usage_report) + mt = time.time() - mt + print('report generation: ', mt) + + +def append_asset(asset_data, **kwargs): # downloaders=[], location=None, + '''Link asset to the scene''' + + file_names = paths.get_download_filenames(asset_data) + props = None + ##### + # how to do particle drop: + # link the group we are interested in( there are more groups in File!!!! , have to get the correct one!) + # + scene = bpy.context.scene + scene['assets used'] = scene.get('assets used', {}) + scene['assets used'][asset_data['asset_base_id']] = asset_data.copy() + + scene['assets rated'] = scene.get('assets rated', {}) + + id = asset_data['asset_base_id'] + scene['assets rated'][id] = scene['assets rated'].get(id, False) + + if asset_data['asset_type'] == 'scene': + scene = append_link.append_scene(file_names[0], link=False, fake_user=False) + props = scene.blenderkit + parent = scene + + if asset_data['asset_type'] == 'model': + s = bpy.context.scene + downloaders = kwargs.get('downloaders') + s = bpy.context.scene + sprops = s.blenderkit_models + # TODO this is here because combinations of linking objects or appending groups are rather not-userfull + if sprops.append_method == 'LINK_GROUP': + sprops.append_link = 'LINK' + sprops.import_as = 'GROUP' + else: + sprops.append_link = 'APPEND' + sprops.import_as = 'INDIVIDUAL' + + # set consistency for objects allready in scene, otherwise this literally breaks blender :) + ain = asset_in_scene(asset_data) + if ain is not False: + if ain == 'LINKED': + sprops.append_link = 'LINK' + sprops.import_as = 'GROUP' + else: + sprops.append_link = 'APPEND' + sprops.import_as = 'INDIVIDUAL' + + # first get conditions for append link + link = sprops.append_link == 'LINK' + # then append link + if downloaders: + for downloader in downloaders: + # this cares for adding particle systems directly to target mesh, but I had to block it now, + # because of the sluggishnes of it. Possibly re-enable when it's possible to do this faster? + if 0: # 'particle_plants' in asset_data['tags']: + append_link.append_particle_system(file_names[-1], + target_object=kwargs['target_object'], + rotation=downloader['rotation'], + link=False, + name=asset_data['name']) + return + + if sprops.import_as == 'GROUP': + parent, newobs = append_link.link_group(file_names[-1], + location=downloader['location'], + rotation=downloader['rotation'], + link=link, + name=asset_data['name']) + + else: + parent, newobs = append_link.append_objects(file_names[-1], + location=downloader['location'], + rotation=downloader['rotation'], + link=link, + name=asset_data['name']) + if parent.type == 'EMPTY' and link: + bmin = asset_data['bbox_min'] + bmax = asset_data['bbox_max'] + size_min = min(1.0, (bmax[0] - bmin[0] + bmax[1] - bmin[1] + bmax[2] - bmin[2]) / 3) + parent.empty_display_size = size_min + + elif kwargs.get('model_location') is not None: + if sprops.import_as == 'GROUP': + parent, newobs = append_link.link_group(file_names[-1], + location=kwargs['model_location'], + rotation=kwargs['model_rotation'], + link=link, + name=asset_data['name']) + else: + parent, newobs = append_link.append_objects(file_names[-1], + location=kwargs['model_location'], + rotation=kwargs['model_rotation'], + link=link) + if parent.type == 'EMPTY' and link: + bmin = asset_data['bbox_min'] + bmax = asset_data['bbox_max'] + size_min = min(1.0, (bmax[0] - bmin[0] + bmax[1] - bmin[1] + bmax[2] - bmin[2]) / 3) + parent.empty_display_size = size_min + + if link: + group = parent.instance_collection + + lib = group.library + lib['asset_data'] = asset_data + + elif asset_data['asset_type'] == 'brush': + + # TODO if allready in scene, should avoid reappending. + inscene = False + for b in bpy.data.brushes: + + if b.blenderkit.id == asset_data['id']: + inscene = True + brush = b + break; + if not inscene: + brush = append_link.append_brush(file_names[-1], link=False, fake_user=False) + + thumbnail_name = asset_data['thumbnail'].split(os.sep)[-1] + tempdir = paths.get_temp_dir('brush_search') + thumbpath = os.path.join(tempdir, thumbnail_name) + asset_thumbs_dir = paths.get_download_dirs('brush')[0] + asset_thumb_path = os.path.join(asset_thumbs_dir, thumbnail_name) + shutil.copy(thumbpath, asset_thumb_path) + brush.icon_filepath = asset_thumb_path + + if bpy.context.sculpt_object: + bpy.context.tool_settings.sculpt.brush = brush + elif bpy.context.image_paint_object: # could be just else, but for future possible more types... + bpy.context.tool_settings.image_paint.brush = brush + # TODO set brush by by asset data(user can be downloading while switching modes.) + + # bpy.context.tool_settings.image_paint.brush = brush + props = brush.blenderkit + parent = brush + + elif asset_data['asset_type'] == 'material': + inscene = False + for m in bpy.data.materials: + if m.blenderkit.id == asset_data['id']: + inscene = True + material = m + break; + if not inscene: + material = append_link.append_material(file_names[-1], link=False, fake_user=False) + target_object = bpy.data.objects[kwargs['target_object']] + + if len(target_object.material_slots) == 0: + target_object.data.materials.append(material) + else: + target_object.material_slots[kwargs['material_target_slot']].material = material + + props = material.blenderkit + parent = material + + parent['asset_data'] = asset_data # TODO remove this??? should write to blenderkit Props? + # moving reporting to on save. + # report_use_success(asset_data['id']) + + +@bpy.app.handlers.persistent +def timer_update(): # TODO might get moved to handle all blenderkit stuff, not to slow down. + '''check for running and finished downloads and react. write progressbars too.''' + global download_threads + if len(download_threads) == 0: + return 1 + s = bpy.context.scene + for threaddata in download_threads: + t = threaddata[0] + asset_data = threaddata[1] + tcom = threaddata[2] + + progress_bars = [] + downloaders = [] + + if t.is_alive(): # set downloader size + sr = bpy.context.scene.get('search results') + if sr is not None: + for r in sr: + if asset_data['id'] == r['id']: + r['downloaded'] = tcom.progress + + if not t.is_alive(): + if tcom.error: + sprops = utils.get_search_props() + sprops.report = tcom.report + download_threads.remove(threaddata) + return + file_names = paths.get_download_filenames(asset_data) + wm = bpy.context.window_manager + + at = asset_data['asset_type'] + if ((bpy.context.mode == 'OBJECT' and (at == 'model' \ + or at == 'material'))) \ + or ((at == 'brush') \ + and wm.get( + 'appendable') == True) or at == 'scene': # don't do this stuff in editmode and other modes, just wait... + download_threads.remove(threaddata) + + # duplicate file if the global and subdir are used in prefs + if len(file_names) == 2: # todo this should try to check if both files exist and are ok. + shutil.copyfile(file_names[0], file_names[1]) + + print('appending asset') + # progress bars: + if bpy.context.scene['search results'] is not None: + for sres in bpy.context.scene['search results']: + if asset_data['id'] == sres['id']: + sres['downloaded'] = 100 + # we need to check if mouse isn't down, which means an operator can be running. + # Especially for sculpt mode, where appending a brush during a sculpt stroke causes crasehes + # + + if tcom.passargs.get('redownload'): + # handle lost libraries here: + for l in bpy.data.libraries: + if l.get('asset_data') is not None and l['asset_data']['id'] == asset_data['id']: + l.filepath = file_names[-1] + l.reload() + else: + done = try_finished_append(asset_data, **tcom.passargs) + if not done: + at = asset_data['asset_type'] + if at in ('model', 'material'): + download(asset_data, **tcom.passargs) + elif asset_data['asset_type'] == 'material': + download(asset_data, **tcom.passargs) + elif asset_data['asset_type'] == 'scene': + download(asset_data, **tcom.passargs) + elif asset_data['asset_type'] == 'brush' or asset_data['asset_type'] == 'texture': + download(asset_data, **tcom.passargs) + print('finished download thread') + return .2 + + +def main_thread(asset_data, tcom, scene_id, api_key): + '''try to download file from blenderkit''' + + # TODO get real link here... + get_download_url(asset_data, scene_id, api_key, tcom=tcom) + if tcom.error: + return + # only now we can check if the file allready exists. This should have 2 levels, for materials and for brushes + # different than for the non free content. delete is here when called after failed append tries. + if check_existing(asset_data) and not tcom.passargs.get('delete'): + # this sends the thread for processing, where another check should occur, since the file might be corrupted. + tcom.downloaded = 100 + print('not downloading, trying to append again') + return; + file_name = paths.get_download_filenames(asset_data)[0] # prefer global dir if possible. + # for k in asset_data: + # print(asset_data[k]) + + with open(file_name, "wb") as f: + print("Downloading %s" % file_name) + headers = {"accept": "application/json", + "Authorization": "Bearer %s" % api_key} + + response = requests.get(asset_data['url'], stream=True) + total_length = response.headers.get('Content-Length') + + if total_length is None: # no content length header + f.write(response.content) + else: + tcom.file_size = int(total_length) + dl = 0 + for data in response.iter_content(chunk_size=4096): + dl += len(data) + tcom.downloaded = dl + tcom.progress = int(100 * tcom.downloaded / tcom.file_size) + f.write(data) + + +class ThreadCom: # object passed to threads to read background process stdout info + def __init__(self): + self.file_size = 1000000000000000 # property that gets written to. + self.downloaded = 0 + self.lasttext = '' + self.error = False + self.report = '' + self.progress = 0.0 + self.passargs = {} + + +def download(asset_data, **kwargs): + '''start the download thread''' + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + api_key = user_preferences.api_key + scene_id = get_scene_id() + if api_key == '': + props = utils.get_search_props() + props.report = 'Register online to use the free library.' + return + tcom = ThreadCom() + + tcom.passargs = kwargs + + # incoming data can be either directly dict from python, or blender id property + # (recovering failed downloads on reload) + if type(asset_data) == dict: + asset_data = copy.deepcopy(asset_data) + else: + asset_data = asset_data.to_dict() + + # main_thread(asset_data, tcom, scene_id, api_key) + readthread = threading.Thread(target=main_thread, args=([asset_data, tcom, scene_id, api_key]), daemon=True) + readthread.start() + + global download_threads + download_threads.append( + [readthread, asset_data, tcom]) + + +def check_downloading(asset_data, **kwargs): + ''' check if an asset is allready downloading, if yes, just make a progress bar with downloader object.''' + global download_threads + + downloading = False + + for p in download_threads: + p_asset_data = p[1] + if p_asset_data['id'] == asset_data['id']: + at = asset_data['asset_type'] + if at in ('model', 'material'): + downloader = {'location': kwargs['model_location'], + 'rotation': kwargs['model_rotation']} + p[2].passargs['downloaders'].append(downloader) + downloading = True + + return downloading + + +def check_existing(asset_data): + ''' check if the object exists on the hard drive''' + fexists = False + + file_names = paths.get_download_filenames(asset_data) + + print('check if file allready exists') + if len(file_names) == 2: + # TODO this should check also for failed or running downloads. + # If download is running, assign just the running thread. if download isn't running but the file is wrong size, + # delete file and restart download (or continue downoad? if possible.) + if os.path.isfile(file_names[0]) and not os.path.isfile(file_names[1]): + shutil.copy(file_names[0], file_names[1]) + elif not os.path.isfile(file_names[0]) and os.path.isfile( + file_names[1]): # only in case of changed settings or deleted/moved global dict. + shutil.copy(file_names[1], file_names[0]) + + if len(file_names) > 0 and os.path.isfile(file_names[0]): + fexists = True + return fexists + + +def try_finished_append(asset_data, **kwargs): # location=None, material_target=None): + ''' try to append asset, if not successfull delete source files. + This means probably wrong download, so download should restart''' + file_names = paths.get_download_filenames(asset_data) + done = False + print('try to append allready existing asset') + if len(file_names) > 0: + if os.path.isfile(file_names[-1]): + kwargs['name'] = asset_data['name'] + try: + append_asset(asset_data, **kwargs) + done = True + except Exception as e: + print(e) + for f in file_names: + try: + os.remove(f) + except: + e = sys.exc_info()[0] + print(e) + pass; + done = False + return done + + +def asset_in_scene(asset_data): + '''checks if the asset is allready in scene. If yes, modifies asset data so the asset can be reached again.''' + scene = bpy.context.scene + au = scene.get('assets used', {}) + + if id in au.keys(): + ad = au[id] + if ad.get('file_name') != None: + asset_data['file_name'] = ad['file_name'] + asset_data['url'] = ad['url'] + + c = bpy.data.collections.get(ad['name']) + if c is not None: + if c.users > 0: + return 'LINKED' + return 'APPENDED' + return False + + +def fprint(text): + print('###################################################################################') + print('\n\n\n') + print(text) + print('\n\n\n') + print('###################################################################################') + + +def get_download_url(asset_data, scene_id, api_key, tcom=None): + ''''retrieves the download url. The server checks if user can download the item.''' + mt = time.time() + headers = { + "accept": "application/json", + "Authorization": "Bearer %s" % api_key, + } + data = { + 'scene_uuid': scene_id + } + r = None + try: + r = requests.get(asset_data['download_url'], params=data, headers=headers) + fprint(r.text) + except Exception as e: + print(e) + if tcom is not None: + tcom.error = True + + if r == None: + tcom.report = 'Connection Error' + tcom.error = True + return 'Connection Error' + if r.status_code < 400: + data = r.json() + url = data['filePath'] + asset_data['url'] = url + asset_data['file_name'] = paths.extract_filename_from_url(url) + return True + + if r.status_code == 403: + tcom.report = 'Available only in higher plans.' + tcom.error = True + return 'Available only in higher plans.' + if r.status_code == 401: + tcom.report = 'Invalid API key' + tcom.error = True + return 'Invalid API key' + elif r.status_code >= 500: + tcom.report = 'Server error' + tcom.error = True + + +def start_download(asset_data, **kwargs): + ''' + check if file isn't downloading or doesn't exist, then start new download + ''' + # first check if the asset is allready in scene. We can use that asset without checking with server + quota_ok = asset_in_scene(asset_data) is not False + + # otherwise, check on server + + s = bpy.context.scene + done = False + # is the asseet being currently downloaded? + downloading = check_downloading(asset_data, **kwargs) + if not downloading: + # check if there are files allready. This check happens 2x once here(for free assets), + # once in thread(for non-free) + fexists = check_existing(asset_data) + + if fexists and quota_ok: + done = try_finished_append(asset_data, **kwargs) + # else: + # props = utils.get_search_props() + # props.report = str('asset ') + if not done: + at = asset_data['asset_type'] + if at in ('model', 'material'): + downloader = {'location': kwargs['model_location'], + 'rotation': kwargs['model_rotation']} + download(asset_data, downloaders=[downloader], **kwargs) + + elif asset_data['asset_type'] == 'scene': + download(asset_data, **kwargs) + elif asset_data['asset_type'] == 'brush' or asset_data['asset_type'] == 'texture': + download(asset_data) + + +asset_types = ( + ('MODEL', 'Model', 'set of objects'), + ('SCENE', 'Scene', 'scene'), + ('MATERIAL', 'Material', 'any .blend Material'), + ('TEXTURE', 'Texture', 'a texture, or texture set'), + ('BRUSH', 'Brush', 'brush, can be any type of blender brush'), + ('ADDON', 'Addon', 'addnon'), +) + + +class BlenderkitDownloadOperator(bpy.types.Operator): + """Download and link asset to scene. Only link if asset allready available locally.""" + bl_idname = "scene.blenderkit_download" + bl_label = "BlenderKit Asset Download" + bl_options = {'REGISTER', 'UNDO'} + + asset_type: EnumProperty( + name="Type", + items=asset_types, + description="Type of download", + default="MODEL", + ) + asset_index: IntProperty(name="Asset Index", description='asset index in search results', default=-1) + + target_object: StringProperty( + name="Material Target Object", + description="", + default="") + material_target_slot: IntProperty(name="Asset Index", description='asset index in search results', default=0) + model_location: FloatVectorProperty(name='Asset Location', default=(0, 0, 0)) + model_rotation: FloatVectorProperty(name='Asset Rotation', default=(0, 0, 0)) + + cast_parent: StringProperty( + name="Particles Target Object", + description="", + default="") + + # @classmethod + # def poll(cls, context): + # return bpy.context.window_manager.BlenderKitModelThumbnails is not '' + + def execute(self, context): + s = bpy.context.scene + sr = s['search results'] + + asset_data = sr[self.asset_index].to_dict() # TODO CHECK ALL OCCURANCES OF PASSING BLENDER ID PROPS TO THREADS! + au = s.get('assets used') + if au == None: + s['assets used'] = {} + if asset_data['asset_base_id'] in s.get('assets used'): + asset_data = s['assets used'][asset_data['asset_base_id']].to_dict() + atype = asset_data['asset_type'] + if bpy.context.mode != 'OBJECT' and ( + atype == 'model' or atype == 'material') and bpy.context.active_object is not None: + bpy.ops.object.mode_set(mode='OBJECT') + kwargs = { + 'cast_parent': self.cast_parent, + 'target_object': self.target_object, + 'material_target_slot': self.material_target_slot, + 'model_location': tuple(self.model_location), + 'model_rotation': tuple(self.model_rotation) + } + + start_download(asset_data, **kwargs) + return {'FINISHED'} + + +def register_download(): + bpy.utils.register_class(BlenderkitDownloadOperator) + bpy.app.handlers.load_post.append(scene_load) + bpy.app.handlers.save_pre.append(scene_save) + # bpy.app.timers.register(timer_update, persistent = True) + + +def unregister_download(): + bpy.utils.unregister_class(BlenderkitDownloadOperator) + bpy.app.handlers.load_post.remove(scene_load) + bpy.app.handlers.save_pre.remove(scene_save) + # bpy.app.timers.unregister(timer_update) diff --git a/blenderkit/overrides.py b/blenderkit/overrides.py new file mode 100644 index 00000000..d9fc8028 --- /dev/null +++ b/blenderkit/overrides.py @@ -0,0 +1,261 @@ +# ##### 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, mathutils +from bpy.types import ( + Operator) + +from blenderkit import utils + + +def getNodes(nt, node_type='OUTPUT_MATERIAL'): + chnodes = nt.nodes[:] + nodes = [] + while len(chnodes) > 0: + n = chnodes.pop() + if n.type == node_type: + nodes.append(n) + if n.type == 'GROUP': + chnodes.extend(n.node_tree.nodes) + return nodes + + +def getShadersCrawl(nt, chnodes): + shaders = [] + done_nodes = chnodes[:] + + while len(chnodes) > 0: + check_node = chnodes.pop() + is_shader = False + for o in check_node.outputs: + if o.type == 'SHADER': + is_shader = True + for i in check_node.inputs: + if i.type == 'SHADER': + is_shader = False # this is for mix nodes and group inputs.. + if len(i.links) > 0: + for l in i.links: + fn = l.from_node + if fn not in done_nodes: + done_nodes.append(fn) + chnodes.append(fn) + if fn.type == 'GROUP': + group_outputs = getNodes(fn.node_tree, node_type='GROUP_OUTPUT') + shaders.extend(getShadersCrawl(fn.node_tree, group_outputs)) + + if check_node.type == 'GROUP': + is_shader = False + + if is_shader: + shaders.append((check_node, nt)) + + return (shaders) + + +def addColorCorrectors(material): + nt = material.node_tree + output = getNodes(nt, 'OUTPUT_MATERIAL')[0] + shaders = getShadersCrawl(nt, [output]) + + correctors = [] + for shader, nt in shaders: + + if shader.type != 'BSDF_TRANSPARENT': # exclude transparent for color tweaks + for i in shader.inputs: + if i.type == 'RGBA': + if len(i.links) > 0: + l = i.links[0] + if not (l.from_node.type == 'GROUP' and l.from_node.node_tree.name == 'bkit_asset_tweaker'): + from_socket = l.from_socket + to_socket = l.to_socket + + g = nt.nodes.new(type='ShaderNodeGroup') + g.node_tree = bpy.data.node_groups['bkit_asset_tweaker'] + g.location = shader.location + g.location.x -= 100 + + nt.links.new(from_socket, g.inputs[0]) + nt.links.new(g.outputs[0], to_socket) + else: + g = l.from_node + tweakers.append(g) + else: + g = nt.nodes.new(type='ShaderNodeGroup') + g.node_tree = bpy.data.node_groups['bkit_asset_tweaker'] + g.location = shader.location + g.location.x -= 100 + + nt.links.new(g.outputs[0], i) + correctors.append(g) + + +def modelProxy(): + s = bpy.context.scene + ao = bpy.context.active_object + if utils.is_linked_asset(ao): + utils.activate(ao) + + g = ao.instance_collection + + rigs = [] + + for ob in g.objects: + if ob.type == 'ARMATURE': + rigs.append(ob) + + if len(rigs) == 1: + + ao.instance_collection = None + bpy.ops.object.duplicate() + new_ao = bpy.context.view_layer.objects.active + new_ao.instance_collection = g + new_ao.empty_display_type = 'SPHERE' + new_ao.empty_display_size *= 0.1 + + bpy.ops.object.proxy_make(object=rigs[0].name) + proxy = bpy.context.active_object + bpy.context.view_layer.objects.active = ao + ao.select_set(True) + new_ao.select_set(True) + new_ao.use_extra_recalc_object = True + new_ao.use_extra_recalc_data = True + bpy.ops.object.parent_set(type='OBJECT', keep_transform=True) + return True + else: # TODO report this to ui + print('not sure what to proxify') + return False + + +class BringToScene(Operator): + """Bring linked object hierarchy to scene and make it editable.""" + + bl_idname = "object.blenderkit_bring_to_scene" + bl_label = "BlenderKit bring objects to scene" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return bpy.context.active_object is not None + + def execute(self, context): + import bpy + + s = bpy.context.scene + sobs = s.collection.all_objects + aob = bpy.context.active_object + dg = aob.instance_collection + instances = [] + + # first, find instances of this collection in the scene + for ob in sobs: + if ob.instance_collection == dg and ob not in instances: + instances.append(ob) + ob.instance_collection = None + ob.instance_type = 'NONE' + # dg.make_local + parent = None + obs = [] + for ob in dg.objects: + dg.objects.unlink(ob) + try: + s.collection.objects.link(ob) + + if ob.parent == None: + parent = ob + bpy.context.view_layer.objects.active = parent + except Exception as e: + print(e) + ob.select_set(True) + obs.append(ob) + bpy.ops.object.make_local(type='ALL') + for ob in obs: + ob.select_set(True) + + related = [] + + for i, ob in enumerate(instances): + if i > 0: + bpy.ops.object.duplicate(linked=True) + + related.append([ob, bpy.context.active_object, mathutils.Vector(bpy.context.active_object.scale)]) + + for relation in related: + bpy.ops.object.select_all(action='DESELECT') + bpy.context.view_layer.objects.active = relation[0] + relation[0].select_set(True) + relation[1].select_set(True) + relation[1].matrix_world = relation[0].matrix_world + relation[1].scale.x = relation[2].x * relation[0].scale.x + relation[1].scale.y = relation[2].y * relation[0].scale.y + relation[1].scale.z = relation[2].z * relation[0].scale.z + bpy.ops.object.parent_set(type='OBJECT', keep_transform=True) + + return {'FINISHED'} + + +class ModelProxy(Operator): + """Tooltip""" + bl_idname = "object.blenderkit_make_proxy" + bl_label = "BlenderKit Make Proxy" + + @classmethod + def poll(cls, context): + return bpy.context.active_object is not None + + def execute(self, context): + result = modelProxy() + if not result: + self.report({'INFO'}, 'No proxy made.There is no armature or more than one in the model.') + return {'FINISHED'} + + +class ColorCorrector(Operator): + """Tooltip""" + bl_idname = "object.blenderkit_color_corrector" + bl_label = "Add color corrector" + + @classmethod + def poll(cls, context): + return bpy.context.active_object is not None + + def execute(self, context): + ao = bpy.context.active_object + g = ao.instance_collection + ao['color correctors'] = [] + mats = [] + + for o in g.objects: + for ms in o.material_slots: + if ms.material not in mats: + mats.append(ms.material) + for mat in mats: + correctors = addColorCorrectors(mat) + + return 'FINISHED' + + +def register_overrides(): + bpy.utils.register_class(BringToScene) + bpy.utils.register_class(ModelProxy) + bpy.utils.register_class(ColorCorrector) + + +def unregister_overrides(): + bpy.utils.unregister_class(BringToScene) + bpy.utils.unregister_class(ModelProxy) + bpy.utils.unregister_class(ColorCorrector) diff --git a/blenderkit/paths.py b/blenderkit/paths.py new file mode 100644 index 00000000..71479c7e --- /dev/null +++ b/blenderkit/paths.py @@ -0,0 +1,217 @@ +# ##### 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, os, sys + +BLENDERKIT_API_LOCAL = "http://localhost:8001/api/v1/" +BLENDERKIT_API_MAIN = "https://www.blenderkit.com/api/v1/" +BLENDERKIT_API_DEVEL = "https://devel.blenderkit.com/api/v1/" +BLENDERKIT_REPORT_URL = "usage_report/" +BLENDERKIT_USER_ASSETS = "https://www.blenderkit.com/my-assets" +BLENDERKIT_PLANS = "https://www.blenderkit.com/plans/pricing/" +BLENDERKIT_MANUAL = "https://youtu.be/1hVgcQhIAo8" +BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL = "https://www.blenderkit.com/docs/upload/" +BLENDERKIT_MATERIAL_UPLOAD_INSTRUCTIONS_URL = "https://www.blenderkit.com/docs/uploading-material/" +BLENDERKIT_SIGNUP_URL = "https://www.blenderkit.com/accounts/register/" +BLENDERKIT_ADDON_URL = "https://www.blenderkit.com/api/v1/assets/6923b215-7df0-46f3-95ae-a2b5ff44ddd5/" +BLENDERKIT_ADDON_FILE_URL = "https://www.blenderkit.com/get-blenderkit/" +_presets = os.path.join(bpy.utils.user_resource('SCRIPTS'), "presets") +BLENDERKIT_SETTINGS_FILENAME = os.path.join(_presets, "bkit.json") + + +def get_bkit_url(): + d = bpy.app.debug_value + # d = 2 + if d == 1: + url = BLENDERKIT_API_LOCAL + elif d == 2: + url = BLENDERKIT_API_DEVEL + else: + url = BLENDERKIT_API_MAIN + return url + + +def get_categories_filepath(): + tempdir = get_temp_dir() + return os.path.join(tempdir, 'categories.json') + + +def get_temp_dir(subdir=None): + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + + # tempdir = user_preferences.temp_dir + tempdir = os.path.join(user_preferences.global_dir, 'temp') + if tempdir.startswith('//'): + tempdir = bpy.path.abspath(tempdir) + if not os.path.exists(tempdir): + os.makedirs(tempdir) + if subdir is not None: + tempdir = tempdir + os.sep + subdir + if not os.path.exists(tempdir): + os.makedirs(tempdir) + return tempdir + + +def get_download_dirs(asset_type): + ''' get directories where assets will be downloaded''' + subdmapping = {'brush': 'brushes', 'texture': 'textures', 'model': 'models', 'scene': 'scenes', + 'material': 'materials'} + + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + dirs = [] + if user_preferences.directory_behaviour == 'BOTH' or 'GLOBAL': + ddir = user_preferences.global_dir + if ddir.startswith('//'): + ddir = bpy.path.abspath(ddir) + if not os.path.exists(ddir): + os.makedirs(ddir) + + subdirs = ['brushes', 'textures', 'models', 'scenes', 'materials'] + for subd in subdirs: + subdir = ddir + os.sep + subd + if not os.path.exists(subdir): + os.makedirs(subdir) + if subdmapping[asset_type] == subd: + dirs.append(subdir) + if ( + user_preferences.directory_behaviour == 'BOTH' or user_preferences.directory_behaviour == 'LOCAL') and bpy.data.is_saved: # it's important local get's solved as second, since for the linking process only last filename will be taken. For download process first name will be taken and if 2 filenames were returned, file will be copied to the 2nd path. + ddir = user_preferences.project_subdir + if ddir.startswith('//'): + ddir = bpy.path.abspath(ddir) + if not os.path.exists(ddir): + os.makedirs(ddir) + + subdirs = ['textures', 'models', 'scenes', 'materials'] # brushes get stored only globally. + for subd in subdirs: + subdir = ddir + os.sep + subd + if not os.path.exists(subdir): + os.makedirs(subdir) + if subdmapping[asset_type] == subd: + dirs.append(subdir) + + return dirs + + +def slugify(slug): + """ + Normalizes string, converts to lowercase, removes non-alpha characters, + and converts spaces to hyphens. + """ + + import unicodedata, re + slug = slug.lower() + slug.replace('.', '_') + slug = slug.replace(' ', '_') + # import re + # slug = unicodedata.normalize('NFKD', slug) + # slug = slug.encode('ascii', 'ignore').lower() + slug = re.sub(r'[^a-z0-9]+.- ', '-', slug).strip('-') + slug = re.sub(r'[-]+', '-', slug) + slug = re.sub(r'/', '_', slug) + return slug + + +def extract_filename_from_url(url): + if url is not None: + imgname = url.split('/')[-1] + imgname = imgname.split('?')[0] + return imgname + return '' + + +def get_download_filenames(asset_data): + dirs = get_download_dirs(asset_data['asset_type']) + file_names = [] + # fn = asset_data['file_name'].replace('blend_', '') + if asset_data.get('url') is not None: + # this means asset is allready in scene and we don't nedd to check + + fn = extract_filename_from_url(asset_data['url']) + fn.replace('_blend', '') + n = slugify(asset_data['name']) + '_' + fn + # n = 'x.blend' + # strs = (n, asset_data['name'], asset_data['file_name']) + for d in dirs: + file_name = os.path.join(d, n) + file_names.append(file_name) + return file_names + + +def delete_asset_debug(asset_data): + from blenderkit import download + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + api_key = user_preferences.api_key + + download.get_download_url(asset_data, download.get_scene_id(), api_key) + + file_names = get_download_filenames(asset_data) + for f in file_names: + if os.path.isfile(f): + try: + print(f) + os.remove(f) + except: + e = sys.exc_info()[0] + print(e) + pass; + + +def get_clean_filepath(): + script_path = os.path.dirname(os.path.realpath(__file__)) + subpath = "blendfiles" + os.sep + "cleaned.blend" + cp = os.path.join(script_path, subpath) + return cp + + +def get_thumbnailer_filepath(): + script_path = os.path.dirname(os.path.realpath(__file__)) + # fpath = os.path.join(p, subpath) + subpath = "blendfiles" + os.sep + "thumbnailer.blend" + return os.path.join(script_path, subpath) + + +def get_material_thumbnailer_filepath(): + script_path = os.path.dirname(os.path.realpath(__file__)) + # fpath = os.path.join(p, subpath) + subpath = "blendfiles" + os.sep + "material_thumbnailer_cycles.blend" + return os.path.join(script_path, subpath) + """ + for p in bpy.utils.script_paths(): + testfname= os.path.join(p, subpath)#p + '%saddons%sobject_fracture%sdata.blend' % (s,s,s) + if os.path.isfile( testfname): + fname=testfname + return(fname) + return None + """ + + +def get_addon_file(subpath=''): + script_path = os.path.dirname(os.path.realpath(__file__)) + # fpath = os.path.join(p, subpath) + return os.path.join(script_path, subpath) + + +def get_addon_thumbnail_path(name): + script_path = os.path.dirname(os.path.realpath(__file__)) + # fpath = os.path.join(p, subpath) + ext = name.split('.')[-1] + next = '' + if not (ext == 'jpg' or ext == 'png'): # allready has ext? + next = '.jpg' + subpath = "thumbnails" + os.sep + name + next + return os.path.join(script_path, subpath) diff --git a/blenderkit/ratings.py b/blenderkit/ratings.py new file mode 100644 index 00000000..6928b87a --- /dev/null +++ b/blenderkit/ratings.py @@ -0,0 +1,207 @@ +# ##### 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 ##### + +if "bpy" in locals(): + import imp + + imp.reload(paths) + +else: + from blenderkit import paths, utils + +import bpy +import requests, threading + +from bpy.props import ( + IntProperty, + FloatProperty, + StringProperty, + EnumProperty, + BoolProperty, + PointerProperty, +) +from bpy.types import ( + Operator, + Panel, +) + + +def pretty_print_POST(req): + """ + At this point it is completely built and ready + to be fired; it is "prepared". + + However pay attention at the formatting used in + this function because it is programmed to be pretty + printed and may differ from the actual request. + """ + print('{}\n{}\n{}\n\n{}'.format( + '-----------START-----------', + req.method + ' ' + req.url, + '\n'.join('{}: {}'.format(k, v) for k, v in req.headers.items()), + req.body, + )) + + +def uplaod_rating_thread(url, ratings, headers): + for rating_name, score in ratings: + if (score != -1 and score != 0): + rating_url = url + rating_name + '/' + data = { + "score": score, # todo this kind of mixing is too much. Should have 2 bkit structures, upload, use + } + + try: + r = requests.put(rating_url, data=data, verify=True, headers=headers) + + except requests.exceptions.RequestException as e: + print('ratings upload failed: %s' % str(e)) + + +def uplaod_review_thread(url, reviews, headers): + r = requests.put(url, data=reviews, verify=True, headers=headers) + + # except requests.exceptions.RequestException as e: + # print('reviews upload failed: %s' % str(e)) + + +def upload_rating(asset): + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + api_key = user_preferences.api_key + headers = {"accept": "application/json", "Authorization": "Bearer %s" % api_key} + + asset_data = asset['asset_data'] + + bkit_ratings = asset.bkit_ratings + print('rating asset', asset_data['name'], asset_data['asset_base_id']) + url = paths.get_bkit_url() + 'assets/' + asset['asset_data']['id'] + '/rating/' + + ratings = [ + ] + + if bkit_ratings.rating_quality > 0.1: + ratings.append(('quality', bkit_ratings.rating_quality)) + if bkit_ratings.rating_work_hours > 0.1: + ratings.append(('working_hours', round(bkit_ratings.rating_work_hours, 1))) + + thread = threading.Thread(target=uplaod_rating_thread, args=(url, ratings, headers)) + thread.start() + + url = paths.get_bkit_url() + 'assets/' + asset['asset_data']['id'] + '/review' + + reviews = { + 'reviewText': bkit_ratings.rating_compliments, + 'reviewTextProblems': bkit_ratings.rating_problems, + } + if not (bkit_ratings.rating_compliments == '' and bkit_ratings.rating_compliments == ''): + thread = threading.Thread(target=uplaod_review_thread, args=(url, reviews, headers)) + thread.start() + + # the info that the user rated an item is stored in the scene + s = bpy.context.scene + s['assets rated'] = s.get('assets rated', {}) + if bkit_ratings.rating_quality > 0.1 and bkit_ratings.rating_work_hours > 0.1: + s['assets rated'][asset['asset_data']['asset_base_id']] = True + + +class StarRatingOperator(bpy.types.Operator): + """Tooltip""" + bl_idname = "object.blenderkit_rating" + bl_label = "Rate the Asset" + + property_name: StringProperty( + name="Rating Property", + description="Property that is rated", + default="", + ) + + rating: IntProperty(name="Rating", description="rating value", default=1, min=1, max=10) + + def execute(self, context): + asset = utils.get_active_asset() + props = asset.bkit_ratings + props[self.property_name] = self.rating + return {'FINISHED'} + + +asset_types = ( + ('MODEL', 'Model', 'set of objects'), + ('SCENE', 'Scene', 'scene'), + ('MATERIAL', 'Material', 'any .blend Material'), + ('TEXTURE', 'Texture', 'a texture, or texture set'), + ('BRUSH', 'Brush', 'brush, can be any type of blender brush'), + ('ADDON', 'Addon', 'addnon'), +) + + +class UploadRatingOperator(bpy.types.Operator): + """Upload rating to the web db""" + bl_idname = "object.blenderkit_rating_upload" + bl_label = "Upload the Rating" + + # type of upload - model, material, textures, e.t.c. + asset_type: EnumProperty( + name="Type", + items=asset_types, + description="Type of asset", + default="MODEL", + ) + + # @classmethod + # def poll(cls, context): + # return bpy.context.active_object != None and bpy.context.active_object.get('asset_id') is not None + def draw(self, context): + layout = self.layout + layout.label(text='Rating sent to server. Thanks for rating!') + + def execute(self, context): + return {'FINISHED'} + + def invoke(self, context, event): + wm = context.window_manager + asset = utils.get_active_asset() + upload_rating(asset) + return wm.invoke_props_dialog(self) + + +def draw_rating(layout, props, prop_name, name): + # layout.label(name) + + row = layout.row(align=True) + + for a in range(0, 10): + if eval('props.' + prop_name) < a + 1: + icon = 'SOLO_OFF' + else: + icon = 'SOLO_ON' + + op = row.operator('object.blenderkit_rating', icon=icon, emboss=False, text='') + op.property_name = prop_name + op.rating = a + 1 + + +def register_ratings(): + pass; + bpy.utils.register_class(StarRatingOperator) + bpy.utils.register_class(UploadRatingOperator) + + +def unregister_ratings(): + pass; + bpy.utils.unregister_class(StarRatingOperator) + bpy.utils.unregister_class(UploadRatingOperator) diff --git a/blenderkit/registration.py b/blenderkit/registration.py new file mode 100644 index 00000000..abcc3caf --- /dev/null +++ b/blenderkit/registration.py @@ -0,0 +1,101 @@ +# ##### 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 json +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlparse + +import requests + +SERVER_URL = "http://localhost:8001" +# SERVER_URL = "https://devel.blenderkit.com" +CLIENT_ID = "IdFRwa3SGA8eMpzhRVFMg5Ts8sPK93xBjif93x0F" +PORTS = [62485, 1234] + + +class SimpleOAuthAuthenticator(object): + def __init__(self, server_url, client_id, ports): + self.server_url = server_url + self.client_id = client_id + self.ports = ports + + def _get_tokens(self, authorization_code=None, refresh_token=None, grant_type="authorization_code"): + data = { + "grant_type": grant_type, + "state": "random_state_string", + "client_id": self.client_id, + "scopes": "read write", + } + if authorization_code: + data['code'] = authorization_code + if refresh_token: + data['refresh_token'] = refresh_token + print(data) + + response = requests.post( + '%s/o/token/' % self.server_url, + data=data + ) + print(response.content) + if response.status_code != 200: + return None, None + refresh_token = json.loads(response.content)['refresh_token'] + access_token = json.loads(response.content)['access_token'] + return access_token, refresh_token + + def get_new_token(self): + class HTTPServerHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + if 'code' in self.path: + self.auth_code = self.path.split('=')[1] + # Display to the user that they no longer need the browser window + self.wfile.write(bytes('<html><h1>You may now close this window.</h1></html>', 'utf-8')) + qs = parse_qs(urlparse(self.path).query) + self.server.authorization_code = qs['code'][0] + + for port in self.ports: + print(port) + try: + httpServer = HTTPServer(('localhost', port), HTTPServerHandler) + except OSError: + continue + break + print(port) + webbrowser.open_new( + "%s/o/authorize?client_id=%s&state=random_state_string&response_type=code&" + "redirect_uri=http://localhost:%s/consumer/exchange/" % (self.server_url, self.client_id, port), + ) + + httpServer.handle_request() + authorization_code = httpServer.authorization_code + return self._get_tokens(authorization_code=authorization_code) + + def get_refreshed_token(self, refresh_token): + return self._get_tokens(refresh_token=refresh_token, grant_type="refresh_token") + + +authenticator = SimpleOAuthAuthenticator(server_url=SERVER_URL, client_id=CLIENT_ID, ports=PORTS) +auth_token, refresh_token = authenticator.get_new_token() +print("auth token: %s, refresh_token: %s" % (auth_token, refresh_token)) +auth_token, refresh_token = authenticator.get_refreshed_token(refresh_token) +print("auth_token: %s, new refresh_token: %s" % (auth_token, refresh_token)) diff --git a/blenderkit/search.py b/blenderkit/search.py new file mode 100644 index 00000000..6a202f09 --- /dev/null +++ b/blenderkit/search.py @@ -0,0 +1,998 @@ +# ##### 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 ##### + +if "bpy" in locals(): + import imp + + imp.reload(paths) + imp.reload(utils) +else: + from blenderkit import paths, utils, categories, ui, version_checker +import blenderkit +from bpy.app.handlers import persistent + +from bpy.props import ( # TODO only keep the ones actually used when cleaning + IntProperty, + FloatProperty, + FloatVectorProperty, + StringProperty, + EnumProperty, + BoolProperty, + PointerProperty, +) +from bpy.types import ( + Operator, + Panel, + AddonPreferences, + PropertyGroup, + UIList +) + +import requests, os, random +import time +import threading +import tempfile +import json +import bpy + +search_start_time = 0 +prev_time = 0 + + +def check_errors(rdata): + if rdata.get('statusCode') == 401: + if rdata.get('detail') == 'Invalid token.': + return False, 'Missing or wrong api_key in addon preferences' + return True, '' + + +search_threads = [] +thumb_sml_download_threads = {} +thumb_full_download_threads = {} +reports = '' + + +@persistent +def scene_load(context): + wm = bpy.context.window_manager + fetch_server_data() + # following doesn't necessarilly happen if version isn't checked yet or similar, first run. + wm['bkit_update'] = version_checker.compare_versions(blenderkit) + utils.load_categories() + + +def fetch_server_data(): + ''' download categories and addon version''' + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + url = paths.BLENDERKIT_ADDON_URL + api_key = user_preferences.api_key + version_checker.check_version_thread(url, api_key, blenderkit) + categories.fetch_categories_thread(api_key) + + +@bpy.app.handlers.persistent +def timer_update(): # TODO might get moved to handle all blenderkit stuff. + + global search_threads + # don't do anything while dragging - this could switch asset type during drag, and make results list lenght different, + # causing a lot of throuble literally. + if len(search_threads) == 0 or bpy.context.scene.blenderkitUI.dragging: + return 1 + for thread in search_threads: # TODO this doesn't check all processess when removal... mostly 1 process will be running however. + + if not thread[0].is_alive(): + search_threads.remove(thread) # + icons_dir = thread[1] + scene = bpy.context.scene + # these 2 lines should update the previews enum and set the first result as active. + s = bpy.context.scene + asset_type = thread[2] + if asset_type == 'model': + props = scene.blenderkit_models + json_filepath = os.path.join(icons_dir, 'model_searchresult.json') + search_name = 'bkit model search' + if asset_type == 'scene': + props = scene.blenderkit_scene + json_filepath = os.path.join(icons_dir, 'scene_searchresult.json') + search_name = 'bkit scene search' + if asset_type == 'material': + props = scene.blenderkit_mat + json_filepath = os.path.join(icons_dir, 'material_searchresult.json') + search_name = 'bkit material search' + if asset_type == 'brush': + props = scene.blenderkit_brush + json_filepath = os.path.join(icons_dir, 'brush_searchresult.json') + search_name = 'bkit brush search' + + s[search_name] = [] + + global reports + if reports != '': + props.report = str(reports) + return .2 + with open(json_filepath, 'r') as data_file: + rdata = json.load(data_file) + + result_field = [] + ok, error = check_errors(rdata) + if ok: + + for r in rdata['results']: + + if r['assetType'] == asset_type: + # print(r) + if len(r['files']) > 0: + furl = None + tname = None + allthumbs = [] + durl, tname = None, None + for f in r['files']: + if f['fileType'] == 'thumbnail': + tname = paths.extract_filename_from_url(f['fileThumbnailLarge']) + small_tname = paths.extract_filename_from_url(f['fileThumbnail']) + allthumbs.append(tname) # TODO just first thumb is used now. + + tdict = {} + for i, t in enumerate(allthumbs): + tdict['thumbnail_%i'] = t + if f['fileType'] == 'blend': + durl = f['downloadUrl'].split('?')[0] + # fname = paths.extract_filename_from_url(f['filePath']) + if durl and tname: + + tooltip = generate_tooltip(r) + # utils.pprint(print(r)) + asset_data = {'thumbnail': tname, + 'thumbnail_small': small_tname, + # 'thumbnails':allthumbs, + 'download_url': durl, + 'id': r['id'], + 'asset_base_id': r['assetBaseId'], + 'name': r['name'], + 'asset_type': r['assetType'], + 'tooltip': tooltip, + 'tags': r['tags'], + 'can_download': r.get('canDownload', True), + 'verification_status': r['verificationStatus'] + # 'description': r['description'], + # 'author': r['description'], + } + asset_data['downloaded'] = 0 + + # parse extra params needed for blender here + params = params_to_dict(r['parameters']) + + if asset_type == 'model': + if params.get('boundBoxMinX') != None: + bbox = { + 'bbox_min': ( + float(params['boundBoxMinX']), + float(params['boundBoxMinY']), + float(params['boundBoxMinZ'])), + 'bbox_max': ( + float(params['boundBoxMaxX']), + float(params['boundBoxMaxY']), + float(params['boundBoxMaxZ'])) + } + + else: + bbox = { + 'bbox_min': (-.5, -.5, 0), + 'bbox_max': (.5, .5, 1) + } + asset_data.update(bbox) + if asset_type == 'material': + asset_data['texture_size_meters'] = params.get('textureSizeMeters', 1.0) + + asset_data.update(tdict) + if r['assetBaseId'] in scene.get('assets used', {}).keys(): + asset_data['downloaded'] = 100 + + result_field.append(asset_data) + + # results = rdata['results'] + s[search_name] = result_field + s['search results'] = result_field + s['search results orig'] = rdata + load_previews() + ui_props = bpy.context.scene.blenderkitUI + if len(result_field) < ui_props.scrolloffset: + ui_props.scrolloffset = 0 + props.is_searching = False + props.search_error = False + props.report = 'Open assetbar to see %i results. ' % len(s['search results']) + if len(s['search results']) == 0: + props.report = 'No matching results found.' + + # (rdata['next']) + # if rdata['next'] != None: + # search(False, get_next = True) + else: + print('error', error) + props.report = error + props.search_error = True + + print('finished search thread') + mt('preview loading finished') + return .2 + + +def load_previews(): + mappingdict = { + 'MODEL': 'model', + 'SCENE': 'scene', + 'MATERIAL': 'material', + 'TEXTURE': 'texture', + 'BRUSH': 'brush' + } + scene = bpy.context.scene + # FIRST START SEARCH + props = scene.blenderkitUI + + directory = paths.get_temp_dir('%s_search' % mappingdict[props.asset_type]) + s = bpy.context.scene + results = s.get('search results') + # + if results is not None: + inames = [] + tpaths = [] + + i = 0 + for r in results: + + tpath = os.path.join(directory, r['thumbnail_small']) + + iname = utils.previmg_name(i) + + if os.path.exists(tpath): # sometimes we are unlucky... + img = bpy.data.images.get(iname) + if img is None: + img = bpy.data.images.load(tpath) + img.name = iname + elif img.filepath != tpath: + # had to add this check for autopacking files... + if img.packed_file is not None: + img.unpack(method='USE_ORIGINAL') + img.filepath = tpath + img.reload() + + i += 1 + print('previews loaded') + + +# line splitting for longer texts... +def split_subs(text): + if text == '': + return [] + threshold = 40 + text = text.rstrip() + lines = [] + while len(text) > threshold: + i = text.rfind(' ', 0, threshold) + i1 = text.rfind(',', 0, threshold) + i = max(i, i1) + if i == -1: + i = threshold + lines.append(text[:i]) + text = text[i:] + lines.append(text) + return lines + + +def list_to_str(input): + output = '' + for i, text in enumerate(input): + output += text + if i < len(input) - 1: + output += ', ' + return output + + +def writeblock(t, input): # for longer texts + dlines = split_subs(input) + for i, l in enumerate(dlines): + t += '%s\n' % l + return t + + +def writeblockm(tooltip, mdata, key='', pretext=None): # for longer texts + if mdata.get(key) == None: + return tooltip + else: + intext = mdata[key] + if type(intext) == list: + intext = list_to_str(intext) + intext = str(intext) + if intext.rstrip() == '': + return tooltip + if pretext == None: + pretext = key + if pretext != '': + pretext = pretext + ': ' + text = pretext + intext + dlines = split_subs(text) + for i, l in enumerate(dlines): + tooltip += '%s\n' % l + + return tooltip + + +def fmt_length(prop): + prop = str(round(prop, 2)) + 'm' + return prop + + +def has(mdata, prop): + if mdata.get(prop) is not None and mdata[prop] is not None and mdata[prop] is not False: + return True + else: + return False + + +def params_to_dict(params): + params_dict = {} + for p in params: + params_dict[p['parameterType']] = p['value'] + return params_dict + + +def generate_tooltip(mdata): + if type(mdata['parameters']) == list: + mparams = params_to_dict(mdata['parameters']) + else: + mparams = mdata['parameters'] + t = '' + t = writeblock(t, mdata['name']) + + t = writeblockm(t, mdata, key='description', pretext='') + if mdata['description'] != '': + t += '\n' + + bools = (('rig', None), ('animated', None), ('manifold', 'non-manifold'), ('scene', None), ('simulation', None), + ('uv', None)) + for b in bools: + if mparams.get(b[0]): + mdata['tags'].append(b[0]) + elif b[1] != None: + mdata['tags'].append(b[1]) + + bools_data = ('adult',) + for b in bools_data: + if mdata.get(b) and mdata[b]: + mdata['tags'].append(b) + t = writeblockm(t, mparams, key='designer', pretext='designer') + t = writeblockm(t, mparams, key='manufacturer', pretext='manufacturer') + t = writeblockm(t, mparams, key='designCollection', pretext='design collection') + + # t = writeblockm(t, mparams, key='engines', pretext='engine') + # t = writeblockm(t, mparams, key='model_style', pretext='style') + # t = writeblockm(t, mparams, key='material_style', pretext='style') + # t = writeblockm(t, mdata, key='tags') + # t = writeblockm(t, mparams, key='condition', pretext='condition') + # t = writeblockm(t, mparams, key='productionLevel', pretext='production level') + if has(mdata, 'purePbr'): + t = writeblockm(t, mparams, key='pbrType', pretext='pbr') + + t = writeblockm(t, mparams, key='designYear', pretext='design year') + + if has(mparams, 'dimensionX'): + t += 'size: %s, %s, %s\n' % (fmt_length(mparams['dimensionX']), + fmt_length(mparams['dimensionY']), + fmt_length(mparams['dimensionZ'])) + if has(mparams, 'faceCount'): + t += 'face count: %s, render: %s\n' % (mparams['faceCount'], mparams['faceCountRender']) + + # t = writeblockm(t, mparams, key='meshPolyType', pretext='mesh type') + # t = writeblockm(t, mparams, key='objectCount', pretext='nubmber of objects') + + # t = writeblockm(t, mparams, key='materials') + # t = writeblockm(t, mparams, key='modifiers') + # t = writeblockm(t, mparams, key='shaders') + + if has(mparams, 'textureSizeMeters'): + t = writeblockm(t, mparams, key='textureSizeMeters', pretext='texture size in meters') + + if has(mparams, 'textureResolutionMax') and mparams['textureResolutionMax'] > 0: + if mparams['textureResolutionMin'] == mparams['textureResolutionMax']: + t = writeblockm(t, mparams, key='textureResolutionMin', pretext='texture resolution') + else: + t += 'tex resolution: %i - %i\n' % (mparams['textureResolutionMin'], mparams['textureResolutionMax']) + + if has(mparams, 'thumbnailScale'): + t = writeblockm(t, mparams, key='thumbnailScale', pretext='preview scale') + + # t += 'uv: %s\n' % mdata['uv'] + t += '\n' + # t = writeblockm(t, mdata, key='license') + + # generator is for both upload preview and search, this is only after search + if mdata.get('versionNumber'): + # t = writeblockm(t, mdata, key='versionNumber', pretext='version') + + t += 'author: %s %s\n' % (mdata['author']['firstName'], mdata['author']['lastName']) + # t += '\n' + + at = mdata['assetType'] + t += '\n' + if at == 'brush' or at == 'texture': + t += 'click to link %s' % mdata['assetType'] + if at == 'model' or at == 'material': + t += 'click or drag in scene to link/append %s' % mdata['assetType'] + + return t + + +def get_items_models(self, context): + global search_items_models + return search_items_models + + +def get_items_brushes(self, context): + global search_items_brushes + return search_items_brushes + + +def get_items_materials(self, context): + global search_items_materials + return search_items_materials + + +def get_items_textures(self, context): + global search_items_textures + return search_items_textures + + +class ThumbDownloader(threading.Thread): + query = None + + def __init__(self, url, path): + super(ThumbDownloader, self).__init__() + self.url = url + self.path = path + self._stop_event = threading.Event() + + def stop(self): + self._stop_event.set() + + def stopped(self): + return self._stop_event.is_set() + + def run(self): + r = requests.get(self.url, stream=False) + if r.status_code == 200: + with open(self.path, 'wb') as f: + f.write(r.content) + # ORIGINALLY WE DOWNLOADED THUMBNAILS AS STREAM, BUT THIS WAS TOO SLOW. + # with open(path, 'wb') as f: + # for chunk in r.iter_content(1048576*4): + # f.write(chunk) + + +class Searcher(threading.Thread): + query = None + + def __init__(self, query, params): + super(Searcher, self).__init__() + self.query = query + self.params = params + self._stop_event = threading.Event() + + def stop(self): + self._stop_event.set() + + def stopped(self): + return self._stop_event.is_set() + + def run(self): + maxthreads = 300 + maximages = 50 + query = self.query + params = self.params + global reports + + t = time.time() + mt('search thread started') + tempdir = paths.get_temp_dir('%s_search' % query['asset_type']) + json_filepath = os.path.join(tempdir, '%s_searchresult.json' % query['asset_type']) + + if query['token'] != '': + headers = { + "accept": "application/json", + "Authorization": "Bearer %s" % query['token'], + # "Content-Type": "application/json", + } + else: + headers = { + "accept": "application/json", + # "Content-Type": "application/json", + } + rdata = {} + rdata['results'] = [] + + if params['get_next']: + with open(json_filepath, 'r') as infile: + try: + origdata = json.load(infile) + urlquery = origdata['next'] + if urlquery == None: + return; + except: + # in case no search results found on drive we don't do next page loading. + params['get_next'] = False + if not params['get_next']: + # build a new request + url = paths.get_bkit_url() + 'search/' + + nquery = { + # 'tags': query['keywords'], + 'asset_type': query['asset_type'], + } + if query.get('category'): + nquery['category_subtree'] = query['category'] + + # build request manually + # TODO use real queries + requeststring = '?query=' + query['keywords'].lower() + '+' + # + for i, q in enumerate(nquery): + requeststring += q + ':' + str(nquery[q]) + if i < len(nquery) - 1: + requeststring += '+' + + requeststring += '&addon_version=%s' % params['addon_version'] + if params.get('scene_uuid') is not None: + requeststring += '&scene_uuid=%s' % params['scene_uuid'] + + urlquery = url + requeststring + + try: + # print(urlquery) + r = requests.get(urlquery, headers=headers) + reports = '' + # print(r.text) + except requests.exceptions.RequestException as e: + print(e) + reports = e + # props.report = e + return + mt('response is back ') + try: + rdata = r.json() + except Exception as inst: + reports = r.text + print(inst) + + mt('data parsed ') + + # filter results here: + # todo remove this in future + nresults = [] + for d in rdata.get('results', []): + # TODO this code is for filtering brush types, should vanish after we implement filter in Elastic + mode = None + if query['asset_type'] == 'brush': + for p in d['parameters']: + if p['parameterType'] == 'mode': + mode = p['value'] + if query['asset_type'] != 'brush' or ( + query.get('brushType') != None and query['brushType']) == mode: + nresults.append(d) + rdata['results'] = nresults + + print('number of results: ', len(rdata.get('results', []))) + if self.stopped(): + print('stopping search : ' + query['keywords']) + return + + mt('search finished') + i = 0 + + thumb_small_urls = [] + thumb_small_filepaths = [] + thumb_full_urls = [] + thumb_full_filepaths = [] + # END OF PARSING + for d in rdata.get('results', []): + for f in d['files']: + # TODO move validation of published assets to server, too manmy checks here. + if f['fileType'] == 'thumbnail' and f['fileThumbnail'] != None and f['fileThumbnailLarge'] != None: + if f['fileThumbnail'] == None: + f['fileThumbnail'] = 'NONE' + if f['fileThumbnailLarge'] == None: + f['fileThumbnailLarge'] = 'NONE' + + thumb_small_urls.append(f['fileThumbnail']) + thumb_full_urls.append(f['fileThumbnailLarge']) + + imgname = paths.extract_filename_from_url(f['fileThumbnail']) + imgpath = os.path.join(tempdir, imgname) + thumb_small_filepaths.append(imgpath) + + imgname = paths.extract_filename_from_url(f['fileThumbnailLarge']) + imgpath = os.path.join(tempdir, imgname) + thumb_full_filepaths.append(imgpath) + + sml_thbs = zip(thumb_small_filepaths, thumb_small_urls) + full_thbs = zip(thumb_full_filepaths, thumb_full_urls) + + # we save here because a missing thumbnail check is in the previous loop + # we can also prepend previous results. These have allready thumbnails downloaded... + if params['get_next']: + rdata['results'][0:0] = origdata['results'] + + with open(json_filepath, 'w') as outfile: + json.dump(rdata, outfile) + + killthreads_sml = [] + for k in thumb_sml_download_threads.keys(): + if k not in thumb_small_filepaths: + killthreads_sml.append(k) # do actual killing here? + + killthreads_full = [] + for k in thumb_full_download_threads.keys(): + if k not in thumb_full_filepaths: + killthreads_full.append(k) # do actual killing here? + # TODO do the killing/ stopping here! remember threads might have finished inbetween! + + if self.stopped(): + print('stopping search : ' + query['keywords']) + return + + # this loop handles downloading of small thumbnails + for imgpath, url in sml_thbs: + if imgpath not in thumb_sml_download_threads and not os.path.exists(imgpath): + thread = ThumbDownloader(url, imgpath) + # thread = threading.Thread(target=download_thumbnail, args=([url, imgpath]), + # daemon=True) + thread.start() + thumb_sml_download_threads[imgpath] = thread + # threads.append(thread) + + if len(thumb_sml_download_threads) > maxthreads: + while len(thumb_sml_download_threads) > maxthreads: + threads_copy = thumb_sml_download_threads.copy() # because for loop can erase some of the items. + for tk, thread in threads_copy.items(): + if not thread.is_alive(): + thread.join() + # print(x) + del (thumb_sml_download_threads[tk]) + print('fetched thumbnail ', i) + i += 1 + if self.stopped(): + print('stopping search : ' + query['keywords']) + return + idx = 0 + while len(thumb_sml_download_threads) > 0: + threads_copy = thumb_sml_download_threads.copy() # because for loop can erase some of the items. + for tk, thread in threads_copy.items(): + if not thread.is_alive(): + thread.join() + del (thumb_sml_download_threads[tk]) + i += 1 + + if self.stopped(): + print('stopping search : ' + query['keywords']) + return + + # start downloading full thumbs in the end + for imgpath, url in full_thbs: + if imgpath not in thumb_full_download_threads and not os.path.exists(imgpath): + thread = ThumbDownloader(url, imgpath) + # thread = threading.Thread(target=download_thumbnail, args=([url, imgpath]), + # daemon=True) + thread.start() + thumb_full_download_threads[imgpath] = thread + mt('thumbnails finished') + + +def build_query_common(query, props): + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + query_common = { + "token": user_preferences.api_key, + "keywords": props.search_keywords + } + query.update(query_common) + + +# def query_add_range(query, name, rmin, rmax): + +def build_query_model(): + '''use all search input to request results from server''' + + props = bpy.context.scene.blenderkit_models + query = { + "asset_type": 'model', + "engine": props.search_engine, + "adult": props.search_adult, + } + if props.search_style != 'ANY': + if props.search_style != 'OTHER': + query["style"] = props.search_style + else: + query["style"] = props.search_style_other + if props.search_advanced: + if props.search_condition != 'UNSPECIFIED': + query["condition"] = props.search_condition + if props.search_design_year: + query["designYearMin"] = props.search_design_year_min + query["designYearMax"] = props.search_design_year_max + if props.search_polycount: + query["polyCountMin"] = props.search_polycount_min + query["polyCountMax"] = props.search_polycount_max + if props.search_texture_resolution: + query["textureResolutionMin"] = props.search_texture_resolution_min + query["textureResolutionMax"] = props.search_texture_resolution_max + + build_query_common(query, props) + # query = { + # "query": { + # "exists": {"field": "faceCount"}, + # + # "range": { + # + # "faceCount": { + # "gte": query["polyCountMin"], + # "lte": query["polyCountMax"], + # "boost": 2.0 + # } + # + # }, + # + # "match": { + # "asset_type": 'model', + # } + # + # } + # } + + return query + + +def build_query_scene(): + '''use all search input to request results from server''' + + props = bpy.context.scene.blenderkit_scene + query = { + "asset_type": 'scene', + "engine": props.search_engine, + # "adult": props.search_adult, + } + build_query_common(query, props) + return query + + +def build_query_material(): + props = bpy.context.scene.blenderkit_mat + query = { + "asset_type": 'material', + + } + if props.search_engine == 'NONE': + query["engine"] = '' + if props.search_engine != 'OTHER': + query["engine"] = props.search_engine + else: + query["engine"] = props.search_engine_other + if props.search_style != 'ANY': + if props.search_style != 'OTHER': + query["style"] = props.search_style + else: + query["style"] = props.search_style_other + build_query_common(query, props) + + return query + + +def build_query_texture(): + props = bpy.context.scene.blenderkit_tex + query = { + "asset_type": 'texture', + + } + + if props.search_style != 'ANY': + if props.search_style != 'OTHER': + query["search_style"] = props.search_style + else: + query["search_style"] = props.search_style_other + + build_query_common(query, props) + + return query + + +def build_query_brush(): + props = bpy.context.scene.blenderkit_brush + + brush_type = '' + if bpy.context.sculpt_object is not None: + brush_type = 'sculpt' + + elif bpy.context.image_paint_object: # could be just else, but for future p + brush_type = 'texture_paint' + + query = { + "asset_type": 'brush', + + "brushType": brush_type + } + + build_query_common(query, props) + + return query + + +def mt(text): + global search_start_time, prev_time + alltime = time.time() - search_start_time + since_last = time.time() - prev_time + prev_time = time.time() + print(text, alltime, since_last) + + +def add_search_process(query, params): + global search_threads + + while (len(search_threads) > 0): + old_thread = search_threads.pop(0) + old_thread[0].stop() + # TODO CARE HERE FOR ALSO KILLING THE THREADS...AT LEAST NOW SEARCH DONE FIRST WON'T REWRITE AN OLDER ONE + + tempdir = paths.get_temp_dir('%s_search' % query['asset_type']) + thread = Searcher(query, params) + # thread = threading.Thread(target=Searcher, args=([query]), daemon=True) + thread.start() + + search_threads.append([thread, tempdir, query['asset_type']]) + + mt('thread started') + + +def search(own=False, category='', get_next=False, free_only=False): + ''' initialize searching''' + global search_start_time + + search_start_time = time.time() + mt('start') + scene = bpy.context.scene + uiprops = scene.blenderkitUI + + if uiprops.asset_type == 'MODEL': + if not hasattr(scene, 'blenderkit'): + return; + props = scene.blenderkit_models + query = build_query_model() + + if uiprops.asset_type == 'SCENE': + if not hasattr(scene, 'blenderkit_scene'): + return; + props = scene.blenderkit_scene + query = build_query_scene() + + if uiprops.asset_type == 'MATERIAL': + if not hasattr(scene, 'blenderkit_mat'): + return; + props = scene.blenderkit_mat + query = build_query_material() + + if uiprops.asset_type == 'TEXTURE': + if not hasattr(scene, 'blenderkit_tex'): + return; + # props = scene.blenderkit_tex + # query = build_query_texture() + + if uiprops.asset_type == 'BRUSH': + if not hasattr(scene, 'blenderkit_brush'): + return; + props = scene.blenderkit_brush + query = build_query_brush() + + if props.is_searching and get_next == True: + return; + query['own'] = own + if category != '': + query['category'] = category + + print('searching') + props.is_searching = True + + params = { + 'scene_uuid': bpy.context.scene.get('uuid', None), + 'addon_version': version_checker.get_addon_version(), + 'get_next': get_next + } + + if free_only: + query['keywords'] += '+is_free:true' + + add_search_process(query, params) + props.report = 'BlenderKit searching....' + + +def search_update(self, context): + if self.search_keywords != '': + search() + + +class SearchOperator(Operator): + """Tooltip""" + bl_idname = "view3d.blenderkit_search" + bl_label = "BlenderKit asset search" + + own: BoolProperty(name="own assets only", + description="Find all own assets", + default=False) + category: StringProperty( + name="category", + description="search only subtree of this category", + default="") + + get_next: BoolProperty(name="next page", + description="get next page from previous search", + default=False) + + @classmethod + def poll(cls, context): + return True + + def execute(self, context): + search(own=self.own, category=self.category, get_next=self.get_next) + bpy.ops.view3d.blenderkit_asset_bar() + + return {'FINISHED'} + + +classes = [ + SearchOperator +] + + +def register_search(): + bpy.app.handlers.load_post.append(scene_load) + + for c in classes: + bpy.utils.register_class(c) + + # bpy.app.timers.register(timer_update, persistent = True) + + utils.load_categories() + + +def unregister_search(): + bpy.app.handlers.load_post.remove(scene_load) + + for c in classes: + bpy.utils.unregister_class(c) + + # bpy.app.timers.unregister(timer_update) + + +''' +search - +build query +START THREAD +send query (bg allready) +get result - metadata, small thumbnails, big thumbnails paths (now genereate this?) +write metadata, possibly to +download small thumbnails first +start big thumbnails download. these don't have to be there on updates, if they aren't the Image in image editor doesn't get updated. +parse metadata, save it in json in the temp dir which gets read on each update of the search. +END THREAD +when download is triggered, get also this metadata from json. E +pass this single - item metadata in the download functions, threads. +''' diff --git a/blenderkit/thumbnails/arrow_left.png b/blenderkit/thumbnails/arrow_left.png Binary files differnew file mode 100644 index 00000000..97565169 --- /dev/null +++ b/blenderkit/thumbnails/arrow_left.png diff --git a/blenderkit/thumbnails/arrow_right.png b/blenderkit/thumbnails/arrow_right.png Binary files differnew file mode 100644 index 00000000..fd16550b --- /dev/null +++ b/blenderkit/thumbnails/arrow_right.png diff --git a/blenderkit/thumbnails/bar_slider.png b/blenderkit/thumbnails/bar_slider.png Binary files differnew file mode 100644 index 00000000..ec627318 --- /dev/null +++ b/blenderkit/thumbnails/bar_slider.png diff --git a/blenderkit/thumbnails/locked.png b/blenderkit/thumbnails/locked.png Binary files differnew file mode 100644 index 00000000..f308392c --- /dev/null +++ b/blenderkit/thumbnails/locked.png diff --git a/blenderkit/thumbnails/locked_large.png b/blenderkit/thumbnails/locked_large.png Binary files differnew file mode 100644 index 00000000..eb2deb65 --- /dev/null +++ b/blenderkit/thumbnails/locked_large.png diff --git a/blenderkit/thumbnails/rating_ui.png b/blenderkit/thumbnails/rating_ui.png Binary files differnew file mode 100644 index 00000000..8d5bbdb5 --- /dev/null +++ b/blenderkit/thumbnails/rating_ui.png diff --git a/blenderkit/thumbnails/star_white.png b/blenderkit/thumbnails/star_white.png Binary files differnew file mode 100644 index 00000000..14e030cc --- /dev/null +++ b/blenderkit/thumbnails/star_white.png diff --git a/blenderkit/thumbnails/thumbnail_notready.jpg b/blenderkit/thumbnails/thumbnail_notready.jpg Binary files differnew file mode 100644 index 00000000..2628d1e5 --- /dev/null +++ b/blenderkit/thumbnails/thumbnail_notready.jpg diff --git a/blenderkit/thumbnails/vs_deleted.png b/blenderkit/thumbnails/vs_deleted.png Binary files differnew file mode 100644 index 00000000..a7f4e134 --- /dev/null +++ b/blenderkit/thumbnails/vs_deleted.png diff --git a/blenderkit/thumbnails/vs_on_hold.png b/blenderkit/thumbnails/vs_on_hold.png Binary files differnew file mode 100644 index 00000000..eb797517 --- /dev/null +++ b/blenderkit/thumbnails/vs_on_hold.png diff --git a/blenderkit/thumbnails/vs_ready.png b/blenderkit/thumbnails/vs_ready.png Binary files differnew file mode 100644 index 00000000..ac52a3cd --- /dev/null +++ b/blenderkit/thumbnails/vs_ready.png diff --git a/blenderkit/thumbnails/vs_uploaded.png b/blenderkit/thumbnails/vs_uploaded.png Binary files differnew file mode 100644 index 00000000..6ef39cb4 --- /dev/null +++ b/blenderkit/thumbnails/vs_uploaded.png diff --git a/blenderkit/ui.py b/blenderkit/ui.py new file mode 100644 index 00000000..40e9b1d8 --- /dev/null +++ b/blenderkit/ui.py @@ -0,0 +1,1424 @@ +# ##### 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 ##### + +if "bpy" in locals(): + import imp + + imp.reload(paths) + imp.reload(ratings) + imp.reload(utils) + imp.reload(search) + imp.reload(upload) +else: + from blenderkit import paths, ratings, utils, search, upload, ui_bgl, download, bg_blender + +import bpy + +import math, random + +from bpy.props import ( + BoolProperty, + StringProperty +) + +from bpy_extras import view3d_utils +import mathutils +from mathutils import Vector +import time +import os + +mappingdict = { + 'MODEL': 'model', + 'SCENE': 'scene', + 'MATERIAL': 'material', + 'TEXTURE': 'texture', + 'BRUSH': 'brush' +} + +verification_icons = { + 'ready': 'vs_ready.png', + 'deleted': 'vs_deleted.png', + 'uploaded': 'vs_uploaded.png', + 'uploading': None, + 'on_hold': 'vs_on_hold.png', + 'validated': None + +} + + +# class UI_region(): +# def _init__(self, parent = None, x = 10,y = 10 , width = 10, height = 10, img = None, col = None): + +def get_approximate_text_width(st): + size = 10 + for s in st: + if s in 'i|': + size += 2 + elif s in ' ': + size += 4 + elif s in 'sfrt': + size += 5 + elif s in 'ceghkou': + size += 6 + elif s in 'PadnBCST3E': + size += 7 + elif s in 'GMODVXYZ': + size += 8 + elif s in 'w': + size += 9 + elif s in 'm': + size += 10 + else: + size += 7 + return size # Convert to picas + + +def get_asset_under_mouse(mousex, mousey): + s = bpy.context.scene + ui_props = bpy.context.scene.blenderkitUI + r = bpy.context.region + + search_results = s.get('search results') + if search_results is not None: + + h_draw = min(ui_props.hcount, math.ceil(len(search_results) / ui_props.wcount)) + for b in range(0, h_draw): + w_draw = min(ui_props.wcount, len(search_results) - b * ui_props.wcount - ui_props.scrolloffset) + for a in range(0, w_draw): + x = ui_props.bar_x + a * (ui_props.margin + ui_props.thumb_size) + ui_props.margin + ui_props.drawoffset + y = ui_props.bar_y - ui_props.margin - (ui_props.thumb_size + ui_props.margin) * (b + 1) + w = ui_props.thumb_size + h = ui_props.thumb_size + + if x < mousex < x + w and y < mousey < y + h: + return a + ui_props.wcount * b + ui_props.scrolloffset + + # return search_results[a] + + return -3 + + +def draw_bbox(location, rotation, bbox_min, bbox_max, progress=None, color=(0, 1, 0, 1)): + ui_props = bpy.context.scene.blenderkitUI + + rotation = mathutils.Euler(rotation) + + smin = Vector(bbox_min) + smax = Vector(bbox_max) + v0 = Vector(smin) + v1 = Vector((smax.x, smin.y, smin.z)) + v2 = Vector((smax.x, smax.y, smin.z)) + v3 = Vector((smin.x, smax.y, smin.z)) + v4 = Vector((smin.x, smin.y, smax.z)) + v5 = Vector((smax.x, smin.y, smax.z)) + v6 = Vector((smax.x, smax.y, smax.z)) + v7 = Vector((smin.x, smax.y, smax.z)) + + arrowx = smin.x + (smax.x - smin.x) / 2 + arrowy = smin.y - (smax.x - smin.x) / 2 + v8 = Vector((arrowx, arrowy, smin.z)) + + vertices = [v0, v1, v2, v3, v4, v5, v6, v7, v8] + for v in vertices: + v.rotate(rotation) + v += Vector(location) + + lines = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], + [2, 6], [3, 7], [0, 8], [1, 8]] + ui_bgl.draw_lines(vertices, lines, color) + if progress != None: + color = (color[0], color[1], color[2], .2) + progress = progress * .01 + vz0 = (v4 - v0) * progress + v0 + vz1 = (v5 - v1) * progress + v1 + vz2 = (v6 - v2) * progress + v2 + vz3 = (v7 - v3) * progress + v3 + rects = ( + (v0, v1, vz1, vz0), + (v1, v2, vz2, vz1), + (v2, v3, vz3, vz2), + (v3, v0, vz0, vz3)) + for r in rects: + ui_bgl.draw_rect_3d(r, color) + + +def get_rating_scalevalues(asset_type): + xs = [] + if asset_type == 'model': + scalevalues = (0.5, 1, 2, 5, 10, 25, 50, 100, 250) + for v in scalevalues: + a = math.log2(v) + x = (a + 1) * (1. / 9.) + xs.append(x) + else: + scalevalues = (0.2, 1, 2, 3, 4, 5) + for v in scalevalues: + a = v + x = v / 5. + xs.append(x) + return scalevalues, xs + + +def draw_ratings_bgl(): + # return; + ui = bpy.context.scene.blenderkitUI + + rating_possible, rated, asset, asset_data = is_rating_possible() + + if rating_possible: # (not rated or ui_props.rating_menu_on): + bkit_ratings = asset.bkit_ratings + bgcol = bpy.context.preferences.themes[0].user_interface.wcol_tooltip.inner + textcol = (1, 1, 1, 1) + + r = bpy.context.region + font_size = int(ui.rating_ui_scale * 20) + + if ui.rating_button_on: + img = utils.get_thumbnail('star_white.png') + + ui_bgl.draw_image(ui.rating_x, + ui.rating_y - ui.rating_button_width, + ui.rating_button_width, + ui.rating_button_width, + img, 1) + + # if ui_props.asset_type != 'BRUSH': + # thumbnail_image = props.thumbnail + # else: + # b = utils.get_active_brush() + # thumbnail_image = b.icon_filepath + + directory = paths.get_temp_dir('%s_search' % asset_data['asset_type']) + tpath = os.path.join(directory, asset_data['thumbnail_small']) + + img = utils.get_hidden_image(tpath, 'rating_preview') + ui_bgl.draw_image(ui.rating_x + ui.rating_button_width, + ui.rating_y - ui.rating_button_width, + ui.rating_button_width, + ui.rating_button_width, + img, 1) + # ui_bgl.draw_text( 'rate asset %s' % asset_data['name'],r.width - rating_button_width + margin, margin, font_size) + return + + ui_bgl.draw_rect(ui.rating_x, + ui.rating_y - ui.rating_ui_height - 2 * ui.margin - font_size, + ui.rating_ui_width + ui.margin, + ui.rating_ui_height + 2 * ui.margin + font_size, + bgcol) + img = utils.get_thumbnail('rating_ui.png') + ui_bgl.draw_image(ui.rating_x, + ui.rating_y - ui.rating_ui_height - 2 * ui.margin, + ui.rating_ui_width, + ui.rating_ui_height, + img, 1) + img = utils.get_thumbnail('star_white.png') + + quality = bkit_ratings.rating_quality + work_hours = bkit_ratings.rating_work_hours + + for a in range(0, quality): + ui_bgl.draw_image(ui.rating_x + ui.quality_stars_x + a * ui.star_size, + ui.rating_y - ui.rating_ui_height + ui.quality_stars_y, + ui.star_size, + ui.star_size, + img, 1) + + img = utils.get_thumbnail('bar_slider.png') + # for a in range(0,11): + if work_hours > 0.2: + if asset_data['asset_type'] == 'model': + complexity = math.log2(work_hours) + 2 # real complexity + complexity = (1. / 9.) * (complexity - 1) * ui.workhours_bar_x_max + else: + complexity = work_hours / 5 * ui.workhours_bar_x_max + ui_bgl.draw_image( + ui.rating_x + ui.workhours_bar_x + int( + complexity), + ui.rating_y - ui.rating_ui_height + ui.workhours_bar_y, + ui.workhours_bar_slider_size, + ui.workhours_bar_slider_size, img, 1) + ui_bgl.draw_text( + str(round(work_hours, 1)), + ui.rating_x + ui.workhours_bar_x - 50, + ui.rating_y - ui.rating_ui_height + ui.workhours_bar_y + 10, font_size) + # (0.5,1,2,4,8,16,32,64,128,256) + # ratings have to be different for models and brushes+materials. + + scalevalues, xs = get_rating_scalevalues(asset_data['asset_type']) + for v, x in zip(scalevalues, xs): + ui_bgl.draw_rect(ui.rating_x + ui.workhours_bar_x + int( + x * ui.workhours_bar_x_max) - 1 + ui.workhours_bar_slider_size / 2, + ui.rating_y - ui.rating_ui_height + ui.workhours_bar_y, + 2, + 5, + textcol) + ui_bgl.draw_text(str(v), + ui.rating_x + ui.workhours_bar_x + int( + x * ui.workhours_bar_x_max), + ui.rating_y - ui.rating_ui_height + ui.workhours_bar_y - 30, + font_size) + if work_hours > 0.2 and quality > 0.2: + text = 'Thanks for rating asset %s' % asset_data['name'] + else: + text = 'Rate asset %s.' % asset_data['name'] + ui_bgl.draw_text(text, + ui.rating_x, + ui.rating_y - ui.margin - font_size, + font_size) + + +def draw_tooltip(x, y, text, img): + region = bpy.context.region + scale = bpy.context.preferences.view.ui_scale + t = time.time() + + ttipmargin = 10 + + font_height = int(12 * scale) + line_height = int(15 * scale) + nameline_height = int(23 * scale) + + lines = text.split('\n') + ncolumns = 2 + nlines = math.ceil((len(lines) - 1) / ncolumns) + texth = line_height * nlines + nameline_height + + isizex = int(512 * scale * img.size[0] / max(img.size[0], img.size[1])) + isizey = int(512 * scale * img.size[1] / max(img.size[0], img.size[1])) + + estimated_height = texth + 3 * ttipmargin + isizey + + if estimated_height > y: + scaledown = y / (estimated_height) + scale *= scaledown + # we need to scale these down to have correct size if the tooltip wouldn't fit. + font_height = int(12 * scale) + line_height = int(15 * scale) + nameline_height = int(23 * scale) + + lines = text.split('\n') + ncolumns = 2 + nlines = math.ceil((len(lines) - 1) / ncolumns) + texth = line_height * nlines + nameline_height + + isizex = int(512 * scale * img.size[0] / max(img.size[0], img.size[1])) + isizey = int(512 * scale * img.size[1] / max(img.size[0], img.size[1])) + + name_height = int(18 * scale) + + x += 2 * ttipmargin + y -= 2 * ttipmargin + + width = isizex + 2 * ttipmargin + x = min(x + width, region.width) - width + + bgcol = bpy.context.preferences.themes[0].user_interface.wcol_tooltip.inner + textcol = bpy.context.preferences.themes[0].user_interface.wcol_tooltip.text + textcol = (textcol[0], textcol[1], textcol[2], 1) + textcol1 = (textcol[0] * .8, textcol[1] * .8, textcol[2] * .8, 1) + white = (1, 1, 1, .1) + + ui_bgl.draw_rect(x - ttipmargin, + y - texth - 2 * ttipmargin - isizey, + isizex + ttipmargin * 2, + texth + 3 * ttipmargin + isizey, + bgcol) + + i = 0 + column_break = -1 # start minus one for the name + xtext = x + fsize = name_height + tcol = textcol + for l in lines: + if column_break >= nlines: + xtext += int(isizex / ncolumns) + column_break = 0 + ytext = y - column_break * line_height - nameline_height - ttipmargin + if i == 0: + ytext = y - name_height + 5 + elif i == len(lines) - 1: + ytext = y - (nlines - 1) * line_height - nameline_height - ttipmargin + tcol = textcol + tsize = font_height + else: + if l[:5] == 'tags:': + tcol = textcol1 + fsize = font_height + i += 1 + column_break += 1 + ui_bgl.draw_text(l, xtext, ytext, fsize, tcol) + t = time.time() + ui_bgl.draw_image(x, y - texth - isizey - ttipmargin, isizex, isizey, img, 1) + + +def draw_callback_2d(self, context): + a = context.area + try: + # self.area might throw error just by itself. + a1 = self.area + go = True + except: + # bpy.types.SpaceView3D.draw_handler_remove(self._handle_2d, 'WINDOW') + # bpy.types.SpaceView3D.draw_handler_remove(self._handle_3d, 'WINDOW') + go = False + if go and a == a1: + props = context.scene.blenderkitUI + if props.down_up == 'SEARCH': + draw_ratings_bgl() + draw_callback_2d_search(self, context) + elif props.down_up == 'UPLOAD': + draw_callback_2d_upload_preview(self, context) + + +def draw_downloader(x, y, percent=0, img=None): + if img is not None: + ui_bgl.draw_image(x, y, 50, 50, img, .5) + ui_bgl.draw_rect(x, y, 50, int(0.5 * percent), (.2, 1, .2, .3)) + ui_bgl.draw_rect(x - 3, y - 3, 6, 6, (1, 0, 0, .3)) + + +def draw_progress(x, y, text='', percent=None, color=(.2, 1, .2, .3)): + ui_bgl.draw_rect(x, y, percent, 5, color) + ui_bgl.draw_text(text, x, y + 8, 10, color) + + +def draw_callback_3d_progress(self, context): + # 'star trek' mode gets here, blocked by now ;) + for threaddata in download.download_threads: + asset_data = threaddata[1] + tcom = threaddata[2] + if tcom.passargs.get('downloaders'): + for d in tcom.passargs['downloaders']: + if asset_data['asset_type'] == 'model': + draw_bbox(d['location'], d['rotation'], asset_data['bbox_min'], asset_data['bbox_max'], + progress=tcom.progress) + + +def draw_callback_2d_progress(self, context): + green = (.2, 1, .2, .3) + offset = 0 + row_height = 35 + + ui = bpy.context.scene.blenderkitUI + + x = ui.reports_x + y = ui.reports_y + index = 0 + for threaddata in download.download_threads: + asset_data = threaddata[1] + tcom = threaddata[2] + if tcom.passargs.get('downloaders'): + for d in tcom.passargs['downloaders']: + directory = paths.get_temp_dir('%s_search' % asset_data['asset_type']) + tpath = os.path.join(directory, asset_data['thumbnail_small']) + img = utils.get_hidden_image(tpath, 'rating_preview') + loc = view3d_utils.location_3d_to_region_2d(bpy.context.region, bpy.context.space_data.region_3d, + d['location']) + if asset_data['asset_type'] == 'model': + # models now draw with star trek mode, no need to draw percent for the image. + draw_downloader(loc[0], loc[1], percent=tcom.progress, img=img) + else: + draw_downloader(loc[0], loc[1], percent=tcom.progress, img=img) + + + else: + draw_progress(x, y - index * 30, text='downloading %s' % asset_data['name'], + percent=tcom.progress) + index += 1 + for process in bg_blender.bg_processes: + tcom = process[1] + draw_progress(x, y - index * 30, '%s' % tcom.lasttext, + tcom.progress) + index += 1 + + +def draw_callback_2d_upload_preview(self, context): + ui_props = context.scene.blenderkitUI + + props = utils.get_upload_props() + if props != None and ui_props.draw_tooltip: + if ui_props.asset_type != 'BRUSH': + ui_props.thumbnail_image = props.thumbnail + else: + b = utils.get_active_brush() + ui_props.thumbnail_image = b.icon_filepath + + img = utils.get_hidden_image(ui_props.thumbnail_image, 'upload_preview') + + draw_tooltip(ui_props.bar_x, ui_props.bar_y, ui_props.tooltip, img) + + +def draw_callback_2d_search(self, context): + s = bpy.context.scene + ui_props = context.scene.blenderkitUI + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + + r = self.region + # hc = bpy.context.preferences.themes[0].view_3d.space.header + # hc = bpy.context.preferences.themes[0].user_interface.wcol_menu_back.inner + # hc = (hc[0], hc[1], hc[2], .2) + hc = (1, 1, 1, .07) + # grey1 = (hc.r * .55, hc.g * .55, hc.b * .55, 1) + grey2 = (hc[0] * .8, hc[1] * .8, hc[2] * .8, .5) + # grey1 = (hc.r, hc.g, hc.b, 1) + white = (1, 1, 1, 0.2) + green = (.2, 1, .2, .7) + highlight = bpy.context.preferences.themes[0].user_interface.wcol_menu_item.inner_sel + highlight = (1, 1, 1, .2) + # highlight = (1, 1, 1, 0.8) + # background of asset bar + if not ui_props.dragging: + search_results = s.get('search results') + if search_results == None: + return + h_draw = min(ui_props.hcount, math.ceil(len(search_results) / ui_props.wcount)) + + if ui_props.wcount > len(search_results): + bar_width = len(search_results) * (ui_props.thumb_size + ui_props.margin) + ui_props.margin + else: + bar_width = ui_props.bar_width + row_height = ui_props.thumb_size + ui_props.margin + ui_bgl.draw_rect(ui_props.bar_x, ui_props.bar_y - ui_props.bar_height, bar_width, + ui_props.bar_height, hc) + + if search_results is not None: + if ui_props.scrolloffset > 0 or ui_props.wcount * ui_props.hcount < len(search_results): + ui_props.drawoffset = 35 + else: + ui_props.drawoffset = 0 + + if ui_props.wcount * ui_props.hcount < len(search_results): + # arrows + arrow_y = ui_props.bar_y - int((ui_props.bar_height + ui_props.thumb_size) / 2) + ui_props.margin + if ui_props.scrolloffset > 0: + + if ui_props.active_index == -2: + ui_bgl.draw_rect(ui_props.bar_x, ui_props.bar_y - ui_props.bar_height, 25, + ui_props.bar_height, highlight) + img = utils.get_thumbnail('arrow_left.png') + ui_bgl.draw_image(ui_props.bar_x, arrow_y, 25, + ui_props.thumb_size, + img, + 1) + if len(search_results) - ui_props.scrolloffset > (ui_props.wcount * ui_props.hcount): + if ui_props.active_index == -1: + ui_bgl.draw_rect(ui_props.bar_x + ui_props.bar_width - 25, + ui_props.bar_y - ui_props.bar_height, 25, + ui_props.bar_height, + highlight) + img1 = utils.get_thumbnail('arrow_right.png') + ui_bgl.draw_image(ui_props.bar_x + ui_props.bar_width - 25, + arrow_y, 25, + ui_props.thumb_size, img1, 1) + + for b in range(0, h_draw): + w_draw = min(ui_props.wcount, len(search_results) - b * ui_props.wcount - ui_props.scrolloffset) + y = ui_props.bar_y - (b + 1) * (row_height) + for a in range(0, w_draw): + x = ui_props.bar_x + a * ( + ui_props.margin + ui_props.thumb_size) + ui_props.margin + ui_props.drawoffset + + # + index = a + ui_props.scrolloffset + b * ui_props.wcount + iname = utils.previmg_name(index) + img = bpy.data.images.get(iname) + + w = int(ui_props.thumb_size * img.size[0] / max(img.size[0], img.size[1])) + h = int(ui_props.thumb_size * img.size[1] / max(img.size[0], img.size[1])) + crop = (0, 0, 1, 1) + if img.size[0] > img.size[1]: + offset = (1 - img.size[1] / img.size[0]) / 2 + crop = (offset, 0, 1 - offset, 1) + if img is not None: + ui_bgl.draw_image(x, y, w, w, img, 1, + crop=crop) + if index == ui_props.active_index: + ui_bgl.draw_rect(x - ui_props.highlight_margin, y - ui_props.highlight_margin, + w + 2 * ui_props.highlight_margin, w + 2 * ui_props.highlight_margin, + highlight) + # if index == ui_props.active_index: + # ui_bgl.draw_rect(x - highlight_margin, y - highlight_margin, + # w + 2*highlight_margin, h + 2*highlight_margin , highlight) + + else: + ui_bgl.draw_rect(x, y, w, h, white) + + result = search_results[index] + if result['downloaded'] > 0: + ui_bgl.draw_rect(x, y - 2, int(w * result['downloaded'] / 100.0), 2, green) + # object type icons - just a test..., adds clutter/ not so userfull: + # icons = ('type_finished.png', 'type_template.png', 'type_particle_system.png') + + if not result.get('can_download', True) == True or user_preferences.api_key == '': + img = utils.get_thumbnail('locked.png') + ui_bgl.draw_image(x + 2, y + 2, 24, 24, img, 1) + + v_icon = verification_icons[result.get('verification_status', 'validated')] + if v_icon is not None: + img = utils.get_thumbnail(v_icon) + ui_bgl.draw_image(x + ui_props.thumb_size - 26, y + 2, 24, 24, img, 1) + + if user_preferences.api_key == '': + report = 'Please register on BlenderKit website to use the free content.' + ui_bgl.draw_text(report, ui_props.bar_x + ui_props.margin, + ui_props.bar_y - 25 - ui_props.margin - ui_props.bar_height, 15) + elif len(search_results) == 0: + report = 'BlenderKit - No matching results found.' + ui_bgl.draw_text(report, ui_props.bar_x + ui_props.margin, + ui_props.bar_y - 25 - ui_props.margin, 15) + s = bpy.context.scene + props = utils.get_search_props() + if props.report != '' and props.is_searching or props.search_error: + ui_bgl.draw_text(props.report, ui_props.bar_x, + ui_props.bar_y - 15 - ui_props.margin - ui_props.bar_height, 15) + + props = s.blenderkitUI + if props.draw_tooltip: + # TODO move this lazy loading into a function and don't duplicate through the code + iname = utils.previmg_name(ui_props.active_index, fullsize=True) + + directory = paths.get_temp_dir('%s_search' % mappingdict[props.asset_type]) + sr = s.get('search results') + if sr != None and ui_props.active_index != -3: + r = sr[ui_props.active_index] + tpath = os.path.join(directory, r['thumbnail']) + + img = bpy.data.images.get(iname) + if img == None or img.filepath != tpath: + if os.path.exists(tpath): # sometimes we are unlucky... + + if img is None: + img = bpy.data.images.load(tpath) + img.name = iname + else: + if img.filepath != tpath: + # todo replace imgs reloads with a method that forces unpack for thumbs. + if img.packed_file is not None: + img.unpack(method='USE_ORIGINAL') + img.filepath = tpath + img.reload() + img.name = iname + else: + iname = utils.previmg_name(ui_props.active_index) + img = bpy.data.images.get(iname) + + draw_tooltip(ui_props.mouse_x, ui_props.mouse_y, ui_props.tooltip, img) + + if ui_props.dragging and ( + ui_props.draw_drag_image or ui_props.draw_snapped_bounds) and ui_props.active_index > -1: + iname = utils.previmg_name(ui_props.active_index) + img = bpy.data.images.get(iname) + linelength = 35 + ui_bgl.draw_image(ui_props.mouse_x + linelength, ui_props.mouse_y - linelength - ui_props.thumb_size, + ui_props.thumb_size, ui_props.thumb_size, img, 1) + ui_bgl.draw_line2d(ui_props.mouse_x, ui_props.mouse_y, ui_props.mouse_x + linelength, + ui_props.mouse_y - linelength, 2, white) + + +def draw_callback_3d(self, context): + ''' Draw snapped bbox while dragging and in the future other blenderkit related stuff. ''' + ui = context.scene.blenderkitUI + + if ui.dragging and ui.asset_type == 'MODEL': + if ui.draw_snapped_bounds: + draw_bbox(ui.snapped_location, ui.snapped_rotation, ui.snapped_bbox_min, ui.snapped_bbox_max) + + +def mouse_raycast(context, mx, my): + r = context.region + rv3d = context.region_data + coord = mx, my + + # get the ray from the viewport and mouse + view_vector = view3d_utils.region_2d_to_vector_3d(r, rv3d, coord) + ray_origin = view3d_utils.region_2d_to_origin_3d(r, rv3d, coord) + ray_target = ray_origin + (view_vector * 1000000000) + + vec = ray_target - ray_origin + + has_hit, snapped_location, snapped_normal, face_index, object, matrix = bpy.context.scene.ray_cast( + bpy.context.view_layer, ray_origin, vec) + + # rote = mathutils.Euler((0, 0, math.pi)) + randoffset = math.pi + if has_hit: + snapped_rotation = snapped_normal.to_track_quat('Z', 'Y').to_euler() + up = Vector((0, 0, 1)) + props = bpy.context.scene.blenderkit_models + if props.randomize_rotation and snapped_normal.angle(up) < math.radians(10.0): + randoffset = props.offset_rotation_amount + math.pi + ( + random.random() - 0.5) * props.randomize_rotation_amount + else: + randoffset = props.offset_rotation_amount # we don't rotate this way on walls and ceilings. + math.pi + # snapped_rotation.z += math.pi + (random.random() - 0.5) * .2 + + else: + snapped_rotation = mathutils.Quaternion((0, 0, 0, 0)).to_euler() + + snapped_rotation.rotate_axis('Z', randoffset) + + return has_hit, snapped_location, snapped_normal, snapped_rotation, face_index, object, matrix + + +def floor_raycast(context, mx, my): + r = context.region + rv3d = context.region_data + coord = mx, my + + # get the ray from the viewport and mouse + view_vector = view3d_utils.region_2d_to_vector_3d(r, rv3d, coord) + ray_origin = view3d_utils.region_2d_to_origin_3d(r, rv3d, coord) + ray_target = ray_origin + (view_vector * 1000) + + # various intersection plane normals are needed for corner cases that might actually happen quite often - in front and side view. + # default plane normal is scene floor. + plane_normal = (0, 0, 1) + if math.isclose(view_vector.x, 0, abs_tol=1e-4) and math.isclose(view_vector.z, 0, abs_tol=1e-4): + plane_normal = (0, 1, 0) + elif math.isclose(view_vector.z, 0, abs_tol=1e-4): + plane_normal = (1, 0, 0) + + snapped_location = mathutils.geometry.intersect_line_plane(ray_origin, ray_target, (0, 0, 0), plane_normal, + False) + if snapped_location != None: + has_hit = True + snapped_normal = Vector((0, 0, 1)) + face_index = None + object = None + matrix = None + snapped_rotation = snapped_normal.to_track_quat('Z', 'Y').to_euler() + props = bpy.context.scene.blenderkit_models + if props.randomize_rotation: + randoffset = props.offset_rotation_amount + math.pi + ( + random.random() - 0.5) * props.randomize_rotation_amount + else: + randoffset = props.offset_rotation_amount + math.pi + snapped_rotation.rotate_axis('Z', randoffset) + + return has_hit, snapped_location, snapped_normal, snapped_rotation, face_index, object, matrix + + +def is_rating_possible(): + ao = bpy.context.active_object + ui = bpy.context.scene.blenderkitUI + if bpy.context.scene.get('assets rated') is not None and ui.down_up == 'SEARCH': + if bpy.context.mode in ('SCULPT', 'PAINT_TEXTURE'): + b = utils.get_active_brush() + ad = b.get('asset_data') + if ad is not None: + rated = bpy.context.scene['assets rated'].get(ad['asset_base_id']) + return True, rated, b, ad + if ao is not None: + # TODO ADD BRUSHES HERE + ad = ao.get('asset_data') + if ad is not None: + rated = bpy.context.scene['assets rated'].get(ad['asset_base_id']) + # originally hidden for allready rated assets + return True, rated, ao, ad + + # check also materials + m = ao.active_material + if m is not None: + ad = m.get('asset_data') + if ad is not None: + rated = bpy.context.scene['assets rated'].get(ad['asset_base_id']) + return True, rated, m, ad + + # if t>2 and t<2.5: + # ui_props.rating_on = False + + return False, False, None, None + + +def interact_rating(r, mx, my, event): + ui = bpy.context.scene.blenderkitUI + rating_possible, rated, asset, asset_data = is_rating_possible() + + if rating_possible: + bkit_ratings = asset.bkit_ratings + + t = time.time() - ui.last_rating_time + # if t>2: + # if rated: + # ui_props.rating_button_on = True + # ui_props.rating_menu_on = False + if ui.rating_button_on and event.type == 'LEFTMOUSE' and event.value == 'RELEASE': + if mouse_in_area(mx, my, + ui.rating_x, + ui.rating_y - ui.rating_button_width, + ui.rating_button_width * 2, + ui.rating_button_width): + ui.rating_menu_on = True + ui.rating_button_on = False + return True + if ui.rating_menu_on: + if mouse_in_area(mx, my, + ui.rating_x, + ui.rating_y - ui.rating_ui_height, + ui.rating_ui_width, + ui.rating_ui_height + 25): + rmx = mx - (ui.rating_x) + rmy = my - (ui.rating_y - ui.rating_ui_height) + + # quality + upload_rating = False + if (ui.quality_stars_x < rmx and rmx < ui.quality_stars_x + 10 * ui.star_size and \ + ui.quality_stars_y < rmy and rmy < ui.quality_stars_y + ui.star_size and event.type == 'LEFTMOUSE' and event.value == 'PRESS') or \ + ui.dragging_rating_quality: + + if event.type == 'LEFTMOUSE': + if event.value == 'PRESS': + ui.dragging_rating = True + ui.dragging_rating_quality = True + elif event.value == 'RELEASE': + ui.dragging_rating = False + ui.dragging_rating_quality = False + + if ui.dragging_rating_quality: + q = math.ceil((rmx - ui.quality_stars_x) / (float(ui.star_size))) + bkit_ratings.rating_quality = q + + # work hours + if ( + ui.workhours_bar_x < rmx and rmx < ui.workhours_bar_x + ui.workhours_bar_x_max + ui.workhours_bar_slider_size and \ + ui.workhours_bar_y < rmy and rmy < ui.workhours_bar_y + ui.workhours_bar_slider_size and event.type == 'LEFTMOUSE' and event.value == 'PRESS') \ + or (ui.dragging_rating_work_hours): + if event.value == 'PRESS': + ui.dragging_rating = True + ui.dragging_rating_work_hours = True + elif event.value == 'RELEASE': + ui.dragging_rating = False + ui.dragging_rating_work_hours = False + if ui.dragging_rating_work_hours: + xv = rmx - ui.workhours_bar_x - ui.workhours_bar_slider_size / 2 + ratio = xv / ui.workhours_bar_x_max + if asset_data['asset_type'] == 'model': + wh_log2 = ratio * 9 - 1 + wh = 2 ** wh_log2 + else: + wh = 5 * ratio + bkit_ratings.rating_work_hours = wh + + if event.type == 'LEFTMOUSE' and event.value == 'RELEASE': + if bkit_ratings.rating_quality > 0.1 or bkit_ratings.rating_work_hours > 0.1: + ratings.upload_rating(asset) + ui.last_rating_time = time.time() + return True + else: + ui.rating_button_on = True + ui.rating_menu_on = False + return False + + +def mouse_in_area(mx, my, x, y, w, h): + if x < mx < x + w and y < my < y + h: + return True + else: + return False + + +def mouse_in_asset_bar(mx, my): + ui_props = bpy.context.scene.blenderkitUI + if ui_props.bar_y - ui_props.bar_height < my < ui_props.bar_y \ + and mx > ui_props.bar_x and mx < ui_props.bar_x + ui_props.bar_width: + return True + else: + return False + + +def mouse_in_region(r, mx, my): + if 0 < my < r.height and 0 < mx < r.width: + return True + else: + return False + + +def update_ui_size(area, region): + ui = bpy.context.scene.blenderkitUI + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + ui_scale = bpy.context.preferences.view.ui_scale + + ui.margin = ui.bl_rna.properties['margin'].default * ui_scale + ui.thumb_size = ui.bl_rna.properties['thumb_size'].default * ui_scale + + reg_multiplier = 1 + if not bpy.context.preferences.system.use_region_overlap: + reg_multiplier = 0 + + for r in area.regions: + if r.type == 'TOOLS': + ui.bar_x = r.width * reg_multiplier + ui.margin + ui.bar_x_offset * ui_scale + elif r.type == 'UI': + ui.bar_end = r.width * reg_multiplier + 100 * ui_scale + + ui.bar_width = region.width - ui.bar_x - ui.bar_end + ui.wcount = math.floor( + (ui.bar_width - 2 * ui.drawoffset) / (ui.thumb_size + ui.margin)) + + search_results = bpy.context.scene.get('search results') + if search_results != None: + ui.hcount = min(user_preferences.max_assetbar_rows, math.ceil(len(search_results) / ui.wcount)) + else: + ui.hcount = 1 + ui.bar_height = (ui.thumb_size + ui.margin) * ui.hcount + ui.margin + ui.bar_y = region.height - ui.bar_y_offset * ui_scale + if ui.down_up == 'UPLOAD': + ui.reports_y = ui.bar_y + 800 + ui.reports_x = ui.bar_x + else: + ui.reports_y = ui.bar_y + ui.bar_height + ui.reports_x = ui.bar_x + + ui.rating_x = ui.bar_x + ui.rating_y = ui.bar_y - ui.bar_height + + +class AssetBarOperator(bpy.types.Operator): + '''runs search and displays the asset bar at the same time''' + bl_idname = "view3d.blenderkit_asset_bar" + bl_label = "BlenderKit Asset Bar UI" + bl_options = {'REGISTER', 'UNDO'} + + do_search: BoolProperty(name="Run Search", description='', default=True, options={'SKIP_SAVE'}) + keep_running: BoolProperty(name="Keep Running", description='', default=True, options={'SKIP_SAVE'}) + free_only: BoolProperty(name="Free Only", description='', default=False, options={'SKIP_SAVE'}) + + category: StringProperty( + name="Category", + description="search only subtree of this category", + default="", options={'SKIP_SAVE'}) + + def search_more(self): + sro = bpy.context.scene.get('search results orig', {}) + if sro.get('next') != None: + search.search(get_next=True) + + def exit_modal(self): + try: + bpy.types.SpaceView3D.draw_handler_remove(self._handle_2d, 'WINDOW') + bpy.types.SpaceView3D.draw_handler_remove(self._handle_3d, 'WINDOW') + except: + pass; + ui_props = bpy.context.scene.blenderkitUI + + ui_props.dragging = False + ui_props.tooltip = '' + ui_props.active_index = -3 + ui_props.draw_drag_image = False + ui_props.draw_snapped_bounds = False + ui_props.has_hit = False + ui_props.assetbar_on = False + + def modal(self, context, event): + # This is for case of closing the area or changing type: + ui_props = context.scene.blenderkitUI + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + + areas = [] + + for w in context.window_manager.windows: + areas.extend(w.screen.areas) + if bpy.context.scene != self.scene: + self.exit_modal() + ui_props.assetbar_on = False + return {'CANCELLED'} + + if self.area not in areas or self.area.type != 'VIEW_3D': + print('search areas') + # stopping here model by now - because of: + # switching layouts or maximizing area now fails to assign new area throwing the bug + # internal error: modal gizmo-map handler has invalid area + self.exit_modal() + ui_props.assetbar_on = False + return {'CANCELLED'} + + newarea = None + for a in context.window.screen.areas: + if a.type == 'VIEW_3D': + self.area = a + for r in a.regions: + if r.type == 'WINDOW': + self.region = r + newarea = a + break; + # context.area = a + + # we check again and quit if things weren't fixed this way. + if newarea == None: + self.exit_modal() + ui_props.assetbar_on = False + return {'CANCELLED'} + + update_ui_size(self.area, self.region) + + search.timer_update() + download.timer_update() + bg_blender.bg_update() + + if context.region != self.region: + return {'PASS_THROUGH'} + + # this was here to check if sculpt stroke is running, but obviously that didn't help, + # since the RELEASE event is cought by operator and thus there is no way to detect a stroke has ended... + if bpy.context.mode in ('SCULPT', 'PAINT_TEXTURE'): + if event.type == 'MOUSEMOVE': # ASSUME THAT SCULPT OPERATOR ACTUALLY STEALS THESE EVENTS, + # SO WHEN THERE ARE SOME WE CAN APPEND BRUSH... + bpy.context.window_manager['appendable'] = True + if event.type == 'LEFTMOUSE': + if event.value == 'PRESS': + bpy.context.window_manager['appendable'] = False + + self.area.tag_redraw() + s = context.scene + + if ui_props.turn_off: + ui_props.assetbar_on = False + ui_props.turn_off = False + self.exit_modal() + ui_props.draw_tooltip = False + return {'CANCELLED'} + + if ui_props.down_up == 'UPLOAD': + + ui_props.mouse_x = 0 + ui_props.mouse_y = self.region.height + + mx = event.mouse_x + my = event.mouse_y + + ui_props.draw_tooltip = True + + # only generate tooltip once in a while + if ( + event.type == 'LEFTMOUSE' or event.type == 'RIGHTMOUSE') and event.value == 'RELEASE' or event.type == 'ENTER' or ui_props.tooltip == '': + ao = bpy.context.active_object + if ui_props.asset_type == 'MODEL' and ao != None \ + or ui_props.asset_type == 'MATERIAL' and ao != None and ao.active_material != None \ + or ui_props.asset_type == 'BRUSH': + export_data, upload_data, eval_path_computing, eval_path_state, eval_path, props = upload.get_upload_data( + self, + context, + ui_props.asset_type) + ui_props.tooltip = search.generate_tooltip(upload_data) + + return {'PASS_THROUGH'} + + # TODO add one more condition here to take less performance. + r = self.region + s = bpy.context.scene + sr = s.get('search results') + + # If there aren't any results, we need no interaction(yet) + if sr is None: + return {'PASS_THROUGH'} + if len(sr) - ui_props.scrolloffset < (ui_props.wcount * ui_props.hcount) + 10: + self.search_more() + if event.type == 'WHEELUPMOUSE' or event.type == 'WHEELDOWNMOUSE' or event.type == 'TRACKPADPAN': + # scrolling + mx = event.mouse_region_x + my = event.mouse_region_y + + if ui_props.dragging and not mouse_in_asset_bar(mx, my): # and my < r.height - ui_props.bar_height \ + # and mx > 0 and mx < r.width and my > 0: + sprops = bpy.context.scene.blenderkit_models + if event.type == 'WHEELUPMOUSE': + sprops.offset_rotation_amount += sprops.offset_rotation_step + elif event.type == 'WHEELDOWNMOUSE': + sprops.offset_rotation_amount -= sprops.offset_rotation_step + + #### TODO - this snapping code below is 3x in this file.... refactor it. + ui_props.has_hit, ui_props.snapped_location, ui_props.snapped_normal, ui_props.snapped_rotation, face_index, object, matrix = mouse_raycast( + context, mx, my) + + # MODELS can be dragged on scene floor + if not ui_props.has_hit and ui_props.asset_type == 'MODEL': + ui_props.has_hit, ui_props.snapped_location, ui_props.snapped_normal, ui_props.snapped_rotation, face_index, object, matrix = floor_raycast( + context, + mx, my) + + return {'RUNNING_MODAL'} + + if not mouse_in_asset_bar(mx, my): + return {'PASS_THROUGH'} + + # note - TRACKPADPAN is unsupported in blender by now. + # if event.type == 'TRACKPADPAN' : + # print(dir(event)) + # print(event.value, event.oskey, event.) + if (event.type == 'WHEELDOWNMOUSE') and len(sr) - ui_props.scrolloffset > ui_props.wcount: + ui_props.scrolloffset += 1 + + if event.type == 'WHEELUPMOUSE' and ui_props.scrolloffset > 0: + ui_props.scrolloffset -= 1 + return {'RUNNING_MODAL'} + if event.type == 'MOUSEMOVE': # Apply + + r = self.region + mx = event.mouse_region_x + my = event.mouse_region_y + + ui_props.mouse_x = mx + ui_props.mouse_y = my + + if ui_props.dragging_rating or ui_props.rating_menu_on: + res = interact_rating(r, mx, my, event) + if res == True: + return {'RUNNING_MODAL'} + + if ui_props.drag_init: + ui_props.drag_length += 1 + if ui_props.drag_length > 0: + ui_props.dragging = True + ui_props.drag_init = False + + if not (ui_props.dragging and mouse_in_region(r, mx, my)) and not mouse_in_asset_bar(mx, my): + ui_props.active_index = -3 + ui_props.draw_tooltip = False + bpy.context.window.cursor_set("DEFAULT") + return {'PASS_THROUGH'} + + sr = bpy.context.scene['search results'] + + if not ui_props.dragging: + bpy.context.window.cursor_set("DEFAULT") + + if sr != None and ui_props.wcount * ui_props.hcount > len(sr) and ui_props.scrolloffset > 0: + ui_props.scrolloffset = 0 + + asset_search_index = get_asset_under_mouse(mx, my) + ui_props.active_index = asset_search_index + if asset_search_index > -1: + + asset_data = sr[asset_search_index] + ui_props.draw_tooltip = True + + ui_props.tooltip = asset_data['tooltip'] + else: + ui_props.draw_tooltip = False + + if mx > ui_props.bar_x + ui_props.bar_width - 50 and len(sr) - ui_props.scrolloffset > ( + ui_props.wcount * ui_props.hcount): + ui_props.active_index = -1 + return {'RUNNING_MODAL'} + if mx < ui_props.bar_x + 50 and ui_props.scrolloffset > 0: + ui_props.active_index = -2 + return {'RUNNING_MODAL'} + + else: + result = False + if ui_props.dragging and not mouse_in_asset_bar(mx, my) and mouse_in_region(r, mx, my): + ui_props.has_hit, ui_props.snapped_location, ui_props.snapped_normal, ui_props.snapped_rotation, face_index, object, matrix = mouse_raycast( + context, mx, my) + # MODELS can be dragged on scene floor + if not ui_props.has_hit and ui_props.asset_type == 'MODEL': + ui_props.has_hit, ui_props.snapped_location, ui_props.snapped_normal, ui_props.snapped_rotation, face_index, object, matrix = floor_raycast( + context, + mx, my) + if ui_props.has_hit and ui_props.asset_type == 'MODEL': + # this condition is here to fix a bug for a scene submitted by a user, so this situation shouldn't + # happen anymore, but there might exists scenes which have this problem for some reason. + if ui_props.active_index<len(sr) and ui_props.active_index>-1: + ui_props.draw_snapped_bounds = True + active_mod = sr[ui_props.active_index] + ui_props.snapped_bbox_min = Vector(active_mod['bbox_min']) + ui_props.snapped_bbox_max = Vector(active_mod['bbox_max']) + + else: + ui_props.draw_snapped_bounds = False + ui_props.draw_drag_image = True + return {'RUNNING_MODAL'} + + if event.type == 'LEFTMOUSE': + + r = self.region + mx = event.mouse_x - r.x + my = event.mouse_y - r.y + + ui_props = context.scene.blenderkitUI + if event.value == 'PRESS' and ui_props.active_index > -1: + if ui_props.asset_type == 'MODEL' or ui_props.asset_type == 'MATERIAL': + ui_props.drag_init = True + bpy.context.window.cursor_set("NONE") + ui_props.draw_tooltip = False + ui_props.drag_length = 0 + + if ui_props.rating_on: + res = interact_rating(r, mx, my, event) + if res: + return {'RUNNING_MODAL'} + + if not ui_props.dragging and not mouse_in_asset_bar(mx, my): + return {'PASS_THROUGH'} + + # this can happen by switching result asset types - length of search result changes + if ui_props.scrolloffset > 0 and (ui_props.wcount * ui_props.hcount) > len(sr) - ui_props.scrolloffset: + ui_props.scrolloffset = len(sr) - (ui_props.wcount * ui_props.hcount) + + if event.value == 'RELEASE': # Confirm + ui_props.drag_init = False + + # scroll by a whole page + if mx > ui_props.bar_x + ui_props.bar_width - 50 and len( + sr) - ui_props.scrolloffset > ui_props.wcount * ui_props.hcount: + ui_props.scrolloffset = min( + ui_props.scrolloffset + (ui_props.wcount * ui_props.hcount), + len(sr) - ui_props.wcount * ui_props.hcount) + return {'RUNNING_MODAL'} + if mx < ui_props.bar_x + 50 and ui_props.scrolloffset > 0: + ui_props.scrolloffset = max(0, ui_props.scrolloffset - ui_props.wcount * ui_props.hcount) + return {'RUNNING_MODAL'} + + # Drag-drop interaction + if ui_props.dragging and mouse_in_region(r, mx, my): + asset_search_index = ui_props.active_index + # raycast here + ui_props.active_index = -3 + + if ui_props.asset_type == 'MODEL': + + ui_props.has_hit, ui_props.snapped_location, ui_props.snapped_normal, ui_props.snapped_rotation, face_index, object, matrix = mouse_raycast( + context, mx, my) + + # MODELS can be dragged on scene floor + if not ui_props.has_hit and ui_props.asset_type == 'MODEL': + ui_props.has_hit, ui_props.snapped_location, ui_props.snapped_normal, ui_props.snapped_rotation, face_index, object, matrix = floor_raycast( + context, + mx, my) + + if not ui_props.has_hit: + return {'RUNNING_MODAL'} + + target_object = '' + if object is not None: + target_object = object.name + target_slot = '' + + if ui_props.asset_type == 'MATERIAL': + ui_props.has_hit, ui_props.snapped_location, ui_props.snapped_normal, ui_props.snapped_rotation, face_index, object, matrix = mouse_raycast( + context, mx, my) + + if not ui_props.has_hit: + # this is last attempt to get object under mouse - for curves and other objects than mesh. + ui_props.dragging = False + sel = utils.selection_get() + bpy.ops.view3d.select(location=(event.mouse_region_x, event.mouse_region_y)) + sel1 = utils.selection_get() + if sel[0] != sel1[0] and sel1[0].type != 'MESH': + object = sel1[0] + target_slot = sel1[0].active_material_index + ui_props.has_hit = True + utils.selection_set(sel) + + if not ui_props.has_hit: + print('select fun') + return {'RUNNING_MODAL'} + + else: + # first, test if object can have material applied. + if object is not None and not object.is_library_indirect: + target_object = object.name + # create final mesh to extract correct material slot + temp_mesh = object.to_mesh(depsgraph=bpy.context.depsgraph, apply_modifiers=True, + calc_undeformed=False) + target_slot = temp_mesh.polygons[face_index].material_index + else: + self.report({'WARNING'}, "Invalid or library object as input:") + target_object = '' + target_slot = '' + + # Click interaction + else: + asset_search_index = get_asset_under_mouse(mx, my) + + if ui_props.asset_type in ('MATERIAL', + 'MODEL'): # this was meant for particles, commenting for now or ui_props.asset_type == 'MODEL': + ao = bpy.context.active_object + if ao != None and not ao.is_library_indirect: + target_object = bpy.context.active_object.name + target_slot = bpy.context.active_object.active_material_index + else: + target_object = '' + target_slot = '' + # FIRST START SEARCH + + if asset_search_index == -3: + return {'RUNNING_MODAL'} + if asset_search_index > -3: + if ui_props.asset_type == 'MATERIAL': + if target_object != '': + # position is for downloader: + loc = ui_props.snapped_location + rotation = (0, 0, 0) + + asset_data = sr[asset_search_index] + utils.automap(target_object, target_slot=target_slot, + tex_size=asset_data.get('texture_size_meters', 1.0)) + bpy.ops.scene.blenderkit_download(True, + asset_type=ui_props.asset_type, + asset_index=asset_search_index, + model_location=loc, + model_rotation=rotation, + target_object=target_object, + material_target_slot=target_slot) + + + elif ui_props.asset_type == 'MODEL': + if ui_props.has_hit and ui_props.dragging: + loc = ui_props.snapped_location + rotation = ui_props.snapped_rotation + else: + loc = s.cursor.location + rotation = s.cursor.rotation_euler + + bpy.ops.scene.blenderkit_download(True, + asset_type=ui_props.asset_type, + asset_index=asset_search_index, + model_location=loc, + model_rotation=rotation, + target_object=target_object) + + else: + bpy.ops.scene.blenderkit_download(asset_type=ui_props.asset_type, + asset_index=asset_search_index) + + ui_props.dragging = False + return {'RUNNING_MODAL'} + else: + return {'RUNNING_MODAL'} + + if event.type == 'X' and ui_props.active_index != -3: + sr = bpy.context.scene['search results'] + asset_data = sr[ui_props.active_index] + print(asset_data['name']) + print('delete') + paths.delete_asset_debug(asset_data) + asset_data['downloaded'] = 0 + return {'RUNNING_MODAL'} + return {'PASS_THROUGH'} + + def invoke(self, context, event): + # FIRST START SEARCH + ui_props = context.scene.blenderkitUI + + if self.do_search: + # we erase search keywords for cateogry search now, since these combinations usually return nothing now. + # when the db gets bigger, this can be deleted. + if self.category != '': + sprops = utils.get_search_props() + sprops.search_keywords = '' + search.search(category=self.category, free_only=self.free_only) + + if ui_props.assetbar_on: + # we don't want to run the assetbar many times, that's why it has a switch on/off behaviour, + # unless being called with 'keep_running' prop. + if not self.keep_running: + # this sends message to the originally running operator, so it quits, and then it ends this one too. + # If it initiated a search, the search will finish in a thread. The switch off procedure is run + # by the 'original' operator, since if we get here, it means + # same operator is allready running. + ui_props.turn_off = True + # if there was an error, we need to turn off these props so we can restart after 2 clicks + ui_props.assetbar_on = False + + else: + pass + return {'FINISHED'} + + ui_props.dragging = False #only for cases where assetbar ended with an error. + ui_props.assetbar_on = True + ui_props.turn_off = False + + sr = bpy.context.scene.get('search results') + if sr is None: + bpy.context.scene['search results'] = [] + + if context.area.type == 'VIEW_3D': + # the arguments we pass the the callback + args = (self, context) + self.area = context.area + self.scene = bpy.context.scene + + for r in self.area.regions: + if r.type == 'WINDOW': + self.region = r + + update_ui_size(self.area, self.region) + + self._handle_2d = bpy.types.SpaceView3D.draw_handler_add(draw_callback_2d, args, 'WINDOW', 'POST_PIXEL') + self._handle_3d = bpy.types.SpaceView3D.draw_handler_add(draw_callback_3d, args, 'WINDOW', 'POST_VIEW') + + context.window_manager.modal_handler_add(self) + ui_props.assetbar_on = True + return {'RUNNING_MODAL'} + else: + + self.report({'WARNING'}, "View3D not found, cannot run operator") + return {'CANCELLED'} + + def execute(self, context): + return {'RUNNING_MODAL'} + + +classess = ( + AssetBarOperator, + +) + +# store keymaps here to access after registration +addon_keymaps = [] + + +def register_ui(): + for c in classess: + bpy.utils.register_class(c) + + args = (None, bpy.context) + bpy.types.SpaceView3D.draw_handler_add(draw_callback_2d_progress, args, 'WINDOW', 'POST_PIXEL') + bpy.types.SpaceView3D.draw_handler_add(draw_callback_3d_progress, args, 'WINDOW', 'POST_VIEW') + + wm = bpy.context.window_manager + + # spaces solved by registering shortcut to Window. Couldn't register object mode before somehow. + if not wm.keyconfigs.addon: + return + km = wm.keyconfigs.addon.keymaps.new(name="Window", space_type='EMPTY') + kmi = km.keymap_items.new(AssetBarOperator.bl_idname, 'SEMI_COLON', 'PRESS', ctrl=False, shift=False) + kmi.properties.keep_running = False + kmi.properties.do_search = False + + addon_keymaps.append(km) + + +def unregister_ui(): + for c in classess: + bpy.utils.unregister_class(c) + + args = (None, bpy.context) + + try: + bpy.types.SpaceView3D.draw_handler_remove(draw_callback_2d_progress, args, 'WINDOW', 'POST_PIXEL') + bpy.types.SpaceView3D.draw_handler_remove(draw_callback_3d_progress, args, 'WINDOW', 'POST_VIEW') + except: + print('handlers allready removed') + wm = bpy.context.window_manager + if not wm.keyconfigs.addon: + return + + for km in addon_keymaps: + wm.keyconfigs.addon.keymaps.remove(km) + del addon_keymaps[:] diff --git a/blenderkit/ui_bgl.py b/blenderkit/ui_bgl.py new file mode 100644 index 00000000..2165cdc5 --- /dev/null +++ b/blenderkit/ui_bgl.py @@ -0,0 +1,127 @@ +# ##### 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 bgl, blf + +import bpy, blf +import gpu +from gpu_extras.batch import batch_for_shader + + +def draw_rect(x, y, width, height, color): + xmax = x + width + ymax = y + height + points = [[x, y], # [x, y] + [x, ymax], # [x, y] + [xmax, ymax], # [x, y] + [xmax, y], # [x, y] + ] + indices = ((0, 1, 2), (2, 3, 0)) + + shader = gpu.shader.from_builtin('2D_UNIFORM_COLOR') + batch = batch_for_shader(shader, 'TRIS', {"pos": points}, indices=indices) + + shader.bind() + shader.uniform_float("color", color) + bgl.glEnable(bgl.GL_BLEND) + batch.draw(shader) + + +def draw_line2d(x1, y1, x2, y2, width, color): + coords = ( + (x1, y1), (x2, y2)) + + indices = ( + (0, 1),) + bgl.glEnable(bgl.GL_BLEND) + + shader = gpu.shader.from_builtin('2D_UNIFORM_COLOR') + batch = batch_for_shader(shader, 'LINES', {"pos": coords}, indices=indices) + shader.bind() + shader.uniform_float("color", color) + batch.draw(shader) + + +def draw_lines(vertices, indices, color): + bgl.glEnable(bgl.GL_BLEND) + + shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR') + batch = batch_for_shader(shader, 'LINES', {"pos": vertices}, indices=indices) + shader.bind() + shader.uniform_float("color", color) + batch.draw(shader) + + +def draw_rect_3d(coords, color): + indices = [(0, 1, 2), (2, 3, 0)] + shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR') + batch = batch_for_shader(shader, 'TRIS', {"pos": coords}, indices=indices) + shader.uniform_float("color", color) + batch.draw(shader) + + +def draw_image(x, y, width, height, image, transparency, crop=(0, 0, 1, 1)): + # draw_rect(x,y, width, height, (.5,0,0,.5)) + + coords = [ + (x, y), (x + width, y), + (x, y + height), (x + width, y + height)] + + uvs = [(crop[0], crop[1]), + (crop[2], crop[1]), + (crop[0], crop[3]), + (crop[2], crop[3]), + ] + + indices = [(0, 1, 2), (2, 1, 3)] + + shader = gpu.shader.from_builtin('2D_IMAGE') + batch = batch_for_shader(shader, 'TRIS', + {"pos": coords, + "texCoord": uvs}, + indices=indices) + + # send image to gpu if it isn't there already + if image.gl_load(): + raise Exception() + + # texture identifier on gpu + texture_id = image.bindcode + + # in case someone disabled it before + bgl.glEnable(bgl.GL_BLEND) + + # bind texture to image unit 0 + bgl.glActiveTexture(bgl.GL_TEXTURE0) + bgl.glBindTexture(bgl.GL_TEXTURE_2D, texture_id) + + shader.bind() + # tell shader to use the image that is bound to image unit 0 + shader.uniform_int("image", 0) + batch.draw(shader) + + bgl.glDisable(bgl.GL_TEXTURE_2D) + + +def draw_text(text, x, y, size, color=(1, 1, 1, 0.5)): + font_id = 0 + # bgl.glColor4f(*color) + blf.color(font_id, color[0], color[1], color[2], color[3]) + blf.position(font_id, x, y, 0) + blf.size(font_id, size, 72) + blf.draw(font_id, text) diff --git a/blenderkit/ui_panels.py b/blenderkit/ui_panels.py new file mode 100644 index 00000000..9444f619 --- /dev/null +++ b/blenderkit/ui_panels.py @@ -0,0 +1,769 @@ +# ##### 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 ##### + +if "bpy" in locals(): + import imp + + imp.reload(paths) + imp.reload(ratings) + imp.reload(utils) + imp.reload(search) + imp.reload(upload) +else: + from blenderkit import paths, ratings, utils, download, categories, ui + +from bpy.types import ( + Panel +) + +import bpy + + +def label_multiline(layout, text='', icon='NONE', width=-1): + ''' draw a ui label, but try to split it in multiple lines.''' + if text.strip() == '': + return + lines = text.split('\n') + if width > 0: + threshold = int(width / 5.5) + else: + threshold = 35 + maxlines = 3 + li =0 + for l in lines: + while len(l) > threshold: + i = l.rfind(' ', 0, threshold) + if i < 1: + i = threshold + l1 = l[:i] + layout.label(text=l1, icon=icon) + icon = 'NONE' + l = l[i:] + li+=1 + if li > maxlines: + break; + if li>maxlines: + break; + layout.label(text=l, icon=icon) + icon = 'NONE' + + +# this was moved to separate interface: + +def draw_ratings(layout, context): + # layout.operator("wm.url_open", text="Read rating instructions", icon='QUESTION').url = 'https://support.google.com/?hl=en' + asset = utils.get_active_asset() + bkit_ratings = asset.bkit_ratings + + ratings.draw_rating(layout, bkit_ratings, 'rating_quality', 'Quality') + layout.separator() + layout.prop(bkit_ratings, 'rating_work_hours') + w = context.region.width + + layout.label(text='problems') + layout.prop(bkit_ratings, 'rating_problems', text='') + layout.label(text='compliments') + layout.prop(bkit_ratings, 'rating_compliments', text='') + + row = layout.row() + op = row.operator("object.blenderkit_rating_upload", text="Send rating", icon='URL') + return op + + +def draw_upload_common(layout, props, asset_type, context): + op = layout.operator("wm.url_open", text="Read upload instructions", + icon='QUESTION') + if asset_type == 'MODEL': + op.url = paths.BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL + if asset_type == 'MATERIAL': + op.url = paths.BLENDERKIT_MATERIAL_UPLOAD_INSTRUCTIONS_URL + + row = layout.row(align=True) + if props.upload_state != '': + label_multiline(layout, text=props.upload_state, width=context.region.width) + if props.uploading: + op = layout.operator('object.kill_bg_process', text="", icon='CANCEL') + op.process_source = asset_type + op.process_type = 'UPLOAD' + layout = layout.column() + layout.enabled = False + + # if props.upload_state.find('Error') > -1: + # layout.label(text = props.upload_state) + + if props.asset_base_id == '': + optext = 'Upload %s' % asset_type.lower() + else: + optext = 'Reupload %s (with thumbnail)' % asset_type.lower() + + op = layout.operator("object.blenderkit_upload", text=optext, icon='EXPORT') + op.asset_type = asset_type + op.as_new = False + + if props.asset_base_id != '': + op = layout.operator("object.blenderkit_upload", text='Reupload only metadata', icon='EXPORT') + op.asset_type = asset_type + op.metadata_only = True + + op = layout.operator("object.blenderkit_upload", text='Upload as new asset', icon='EXPORT') + op.asset_type = asset_type + op.as_new = True + # layout.label(text = 'asset id, overwrite only for reuploading') + if props.asset_base_id != '': + row = layout.row() + + row.prop(props, 'asset_base_id', icon='FILE_TICK') + layout.operator("object.blenderkit_mark_for_validation", icon='EXPORT') + + layout.prop(props, 'category') + if asset_type == 'MODEL' and props.subcategory != '': # by now block this for other asset types. + layout.prop(props, 'subcategory') + + layout.prop(props, 'license') + + +def poll_local_panels(): + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + return user_preferences.panel_behaviour == 'BOTH' or user_preferences.panel_behaviour == 'LOCAL' + + +def prop_needed(layout, props, name, value, is_not_filled=''): + row = layout.row() + if value == is_not_filled: + # row.label(text='', icon = 'ERROR') + icon = 'ERROR' + row.prop(props, name, icon=icon) + else: + # row.label(text='', icon = 'FILE_TICK') + icon = None + row.prop(props, name) + + +def draw_panel_model_upload(self, context): + ob = bpy.context.active_object + while ob.parent is not None: + ob = ob.parent + props = ob.blenderkit + + layout = self.layout + + draw_upload_common(layout, props, 'MODEL', context) + + prop_needed(layout, props, 'name', props.name) + + col = layout.column() + if props.is_generating_thumbnail: + col.enabled = False + prop_needed(col, props, 'thumbnail', props.has_thumbnail, False) + if bpy.context.scene.render.engine in ('CYCLES', 'BLENDER_EEVEE'): + col.operator("object.blenderkit_generate_thumbnail", text='Generate thumbnail', icon='IMAGE') + + # row = layout.row(align=True) + if props.is_generating_thumbnail: + row = layout.row(align=True) + row.label(text=props.thumbnail_generating_state) + op = row.operator('object.kill_bg_process', text="", icon='CANCEL') + op.process_source = 'MODEL' + op.process_type = 'THUMBNAILER' + elif props.thumbnail_generating_state != '': + label_multiline(layout, text=props.thumbnail_generating_state) + + layout.prop(props, 'description') + prop_needed(layout, props, 'tags', props.tags) + # prop_needed(layout, props, 'style', props.style) + # prop_needed(layout, props, 'production_level', props.production_level) + layout.prop(props, 'style') + layout.prop(props, 'production_level') + + layout.prop(props, 'condition') + layout.prop(props, 'is_free') + layout.prop(props, 'pbr') + layout.label(text='design props:') + layout.prop(props, 'manufacturer') + layout.prop(props, 'designer') + layout.prop(props, 'design_collection') + layout.prop(props, 'design_variant') + layout.prop(props, 'use_design_year') + if props.use_design_year: + layout.prop(props, 'design_year') + + row = layout.row() + if props.work_hours == 0: + row.label(text='', icon='ERROR') + row.prop(props, 'work_hours') + + layout.prop(props, 'adult') + + +def draw_panel_scene_upload(self, context): + s = bpy.context.scene + props = s.blenderkit + + layout = self.layout + if bpy.app.debug_value != -1: + layout.label(text='Scene upload not Implemented') + return + draw_upload_common(layout, props, 'SCENE', context) + + # layout = layout.column() + + # row = layout.row() + + # if props.dimensions[0] + props.dimensions[1] == 0 and props.face_count == 0: + # icon = 'ERROR' + # layout.operator("object.blenderkit_auto_tags", text='Auto fill tags', icon=icon) + # else: + # layout.operator("object.blenderkit_auto_tags", text='Auto fill tags') + + prop_needed(layout, props, 'name', props.name) + + col = layout.column() + # if props.is_generating_thumbnail: + # col.enabled = False + prop_needed(col, props, 'thumbnail', props.has_thumbnail, False) + # if bpy.context.scene.render.engine == 'CYCLES': + # col.operator("object.blenderkit_generate_thumbnail", text='Generate thumbnail', icon='IMAGE_COL') + + # row = layout.row(align=True) + # if props.is_generating_thumbnail: + # row = layout.row(align=True) + # row.label(text = props.thumbnail_generating_state) + # op = row.operator('object.kill_bg_process', text="", icon='CANCEL') + # op.process_source = 'MODEL' + # op.process_type = 'THUMBNAILER' + # elif props.thumbnail_generating_state != '': + # label_multiline(layout, text = props.thumbnail_generating_state) + + layout.prop(props, 'description') + prop_needed(layout, props, 'tags', props.tags) + layout.prop(props, 'style') + layout.prop(props, 'production_level') + layout.prop(props, 'use_design_year') + if props.use_design_year: + layout.prop(props, 'design_year') + layout.prop(props, 'condition') + row = layout.row() + if props.work_hours == 0: + row.label(text='', icon='ERROR') + row.prop(props, 'work_hours') + layout.prop(props, 'adult') + + +def draw_panel_model_search(self, context): + s = context.scene + props = s.blenderkit_models + layout = self.layout + layout.prop(props, "search_keywords", text="", icon='VIEWZOOM') + + icon = 'NONE' + if props.report == 'Available only in higher plans.': + icon = 'ERROR' + label_multiline(layout, text=props.report, icon=icon) + if props.report == 'Available only in higher plans.': + layout.operator("wm.url_open", text="Check plans", icon='URL').url = paths.BLENDERKIT_PLANS + + layout.prop(props, "search_style") + # if props.search_style == 'OTHER': + # layout.prop(props, "search_style_other") + # layout.prop(props, "search_engine") + # col = layout.column() + # layout.prop(props, 'append_link', expand=True, icon_only=False) + # layout.prop(props, 'import_as', expand=True, icon_only=False) + + # layout.prop(props, "search_advanced") + if props.search_advanced: + layout.separator() + + # layout.label(text = "common searches keywords:") + # layout.prop(props, "search_global_keywords", text = "") + # layout.prop(props, "search_modifier_keywords") + if props.search_engine == 'OTHER': + layout.prop(props, "search_engine_keyword") + + # AGE + layout.prop(props, "search_condition") # , text ='condition of object new/old e.t.c.') + + # DESIGN YEAR + layout.prop(props, "search_design_year", text='designed in ( min - max )') + row = layout.row(align=True) + if not props.search_design_year_min: + row.active = False + row.prop(props, "search_design_year_min", text='min') + row.prop(props, "search_design_year_max", text='max') + + # POLYCOUNT + layout.prop(props, "search_polycount", text='polycount in ( min - max )') + row = layout.row(align=True) + if not props.search_polycount: + row.active = False + row.prop(props, "search_polycount_min", text='min') + row.prop(props, "search_polycount_max", text='max') + + # TEXTURE RESOLUTION + layout.prop(props, "search_texture_resolution", text='texture resolution ( min - max )') + row = layout.row(align=True) + if not props.search_texture_resolution: + row.active = False + row.prop(props, "search_texture_resolution_min", text='min') + row.prop(props, "search_texture_resolution_max", text='max') + + # ADULT + layout.prop(props, "search_adult") # , text ='condition of object new/old e.t.c.') + + draw_panel_categories(self, context) + + layout.separator() + layout.label(text='how to import assets:') + col = layout.column() + col.prop(props, 'append_method', expand=True, icon_only=False) + layout.prop(props, 'randomize_rotation') + if props.randomize_rotation: + layout.prop(props, 'randomize_rotation_amount') + + +def draw_panel_scene_search(self, context): + s = context.scene + props = s.blenderkit_scene + layout = self.layout + # layout.label(text = "common search properties:") + layout.prop(props, "search_keywords", text="", icon='VIEWZOOM') + + label_multiline(layout, text=props.report) + + layout.prop(props, "search_style") + if props.search_style == 'OTHER': + layout.prop(props, "search_style_other") + layout.prop(props, "search_engine") + layout.separator() + draw_panel_categories(self, context) + + +class VIEW3D_PT_blenderkit_model_properties(Panel): + bl_category = "BlenderKit" + bl_idname = "VIEW3D_PT_blenderkit_model_properties" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_label = "Model tweaking" + bl_context = "objectmode" + + @classmethod + def poll(cls, context): + p = bpy.context.active_object is not None and bpy.context.active_object.get('asset_data') is not None + return p + + def draw(self, context): + # draw asset properties here + layout = self.layout + + o = bpy.context.active_object + ad = o['asset_data'] + layout.label(text=str(ad['name'])) + # proxies just don't make it in 2.79... they should stay hidden and used only by pros ... + # if 'rig' in ad['tags']: + # # layout.label(text = 'can make proxy') + # layout.operator('object.blenderkit_make_proxy', text = 'Make Armature proxy') + + layout.operator('object.blenderkit_bring_to_scene', text='Bring to scene') + # layout.operator('object.blenderkit_color_corrector') + + +def draw_panel_model_rating(self, context): + o = bpy.context.active_object + op = draw_ratings(self.layout, context) # , props) + op.asset_type = 'MODEL' + + +def draw_panel_material_upload(self, context): + o = bpy.context.active_object + mat = bpy.context.active_object.active_material + + props = mat.blenderkit + layout = self.layout + + draw_upload_common(layout, props, 'MATERIAL', context) + + prop_needed(layout, props, 'name', props.name) + layout.prop(props, 'description') + layout.prop(props, 'style') + if props.style == 'OTHER': + layout.prop(props, 'style_other') + # layout.prop(props, 'engine') + # if props.engine == 'OTHER': + # layout.prop(props, 'engine_other') + prop_needed(layout, props, 'tags', props.tags) + # layout.prop(props,'shaders')#TODO autofill on upload + # row = layout.row() + layout.prop(props, 'pbr') + layout.prop(props, 'uv') + layout.prop(props, 'animated') + layout.prop(props, 'texture_size_meters') + + # THUMBNAIL + row = layout.row() + if props.is_generating_thumbnail: + row.enabled = False + prop_needed(row, props, 'thumbnail', props.has_thumbnail, False) + + if props.is_generating_thumbnail: + row = layout.row(align=True) + row.label(text=props.thumbnail_generating_state, icon='RENDER_STILL') + op = row.operator('object.kill_bg_process', text="", icon='CANCEL') + op.process_source = 'MATERIAL' + op.process_type = 'THUMBNAILER' + elif props.thumbnail_generating_state != '': + label_multiline(layout, text=props.thumbnail_generating_state) + + if bpy.context.scene.render.engine in ('CYCLES', 'BLENDER_EEVEE'): + layout.operator("object.blenderkit_material_thumbnail", text='Render thumbnail with Cycles', icon='EXPORT') + + # tname = "." + bpy.context.active_object.active_material.name + "_thumbnail" + # if props.has_thumbnail and bpy.data.textures.get(tname) is not None: + # row = layout.row() + # # row.scale_y = 1.5 + # row.template_preview(bpy.data.textures[tname], preview_id='test') + + +def draw_panel_material_search(self, context): + wm = context.scene + props = wm.blenderkit_mat + + layout = self.layout + layout.prop(props, "search_keywords", text="", icon='VIEWZOOM') + + label_multiline(layout, text=props.report) + + layout.prop(props, 'search_style') + if props.search_style == 'OTHER': + layout.prop(props, 'search_style_other') + layout.prop(props, 'search_engine') + if props.search_engine == 'OTHER': + layout.prop(props, 'search_engine_other') + + layout.prop(props, 'automap') + + draw_panel_categories(self, context) + + +def draw_panel_material_ratings(self, context): + op = draw_ratings(self.layout, context) # , props) + op.asset_type = 'MATERIAL' + + +def draw_panel_brush_upload(self, context): + brush = utils.get_active_brush() + if brush is not None: + props = brush.blenderkit + + layout = self.layout + + draw_upload_common(layout, props, 'BRUSH', context) + + layout.prop(props, 'name') + layout.prop(props, 'description') + layout.prop(props, 'tags') + + +def draw_panel_brush_search(self, context): + wm = context.scene + props = wm.blenderkit_brush + + layout = self.layout + layout.prop(props, "search_keywords", text="", icon='VIEWZOOM') + label_multiline(layout, text=props.report) + draw_panel_categories(self, context) + + +def draw_panel_brush_ratings(self, context): + # props = utils.get_brush_props(context) + op = draw_ratings(self.layout, context) # , props) + + op.asset_type = 'BRUSH' + + +class VIEW3D_PT_blenderkit_unified(Panel): + bl_category = "BlenderKit" + bl_idname = "VIEW3D_PT_blenderkit_unified" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_label = "BlenderKit" + + @classmethod + def poll(cls, context): + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + return user_preferences.panel_behaviour == 'BOTH' or user_preferences.panel_behaviour == 'UNIFIED' + + def draw(self, context): + s = context.scene + ui_props = s.blenderkitUI + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + wm = bpy.context.window_manager + layout = self.layout + + row = layout.row() + + # + row.prop(ui_props, 'down_up', expand=True, icon_only=True) + # row.label('') + row = row.split().row() + row.prop(ui_props, 'asset_type', expand=True, icon_only=True) + + w = context.region.width + + if user_preferences.api_key == '': + op = layout.operator("wm.url_open", text="Register online", + icon='QUESTION') + op.url = paths.BLENDERKIT_SIGNUP_URL + layout.label(text='Paste your API Key:') + layout.prop(user_preferences, 'api_key', text='') + layout.separator() + elif bpy.data.filepath == '': + + label_multiline(layout, text="It's better to save the file first.", width=w) + layout.separator() + if wm.get('bkit_update'): + label_multiline(layout, text="New version available!", icon='INFO', width=w) + layout.operator("wm.url_open", text="Get new version", + icon='URL').url = paths.BLENDERKIT_ADDON_FILE_URL + layout.separator() + layout.separator() + layout.separator() + if ui_props.down_up == 'SEARCH': + # global assetbar_on + if not ui_props.assetbar_on: + icon = 'EXPORT' + text = 'Show AssetBar - ;' + row = layout.row() + sr = bpy.context.scene.get('search results') + if sr != None: + icon = 'RESTRICT_VIEW_OFF' + row.scale_y = 1 + text = 'Show Assetbar to see %i results - ;' % len(sr) + op = row.operator('view3d.blenderkit_asset_bar', text=text, icon=icon) + + else: + + op = layout.operator('view3d.blenderkit_asset_bar', text='Hide AssetBar - ;', icon='EXPORT') + op.keep_running = False + op.do_search = False + + if ui_props.asset_type == 'MODEL': + # noinspection PyCallByClass + draw_panel_model_search(self, context) + if ui_props.asset_type == 'SCENE': + # noinspection PyCallByClass + draw_panel_scene_search(self, context) + + elif ui_props.asset_type == 'MATERIAL': + draw_panel_material_search(self, context) + elif ui_props.asset_type == 'BRUSH': + if context.sculpt_object or context.image_paint_object: + # noinspection PyCallByClass + draw_panel_brush_search(self, context) + else: + label_multiline(layout, text='switch to paint or sculpt mode.', width=context.region.width) + return + + # blocking this now. It became terribly slow. + layout.operator("wm.url_open", text="See my uploads", + icon='URL').url = paths.BLENDERKIT_USER_ASSETS + elif ui_props.down_up == 'UPLOAD': + if not ui_props.assetbar_on: + text = 'Show asset preview - ;' + else: + text = 'Hide asset preview - ;' + op = layout.operator('view3d.blenderkit_asset_bar', text=text, icon='EXPORT') + op.keep_running = False + op.do_search = False + e = s.render.engine + if e not in ('CYCLES', 'BLENDER_EEVEE'): + rtext = 'Only Cycles and EEVEE render engines are currently supported. ' \ + 'Please use Cycles for all assets you upload to BlenderKit.' + label_multiline(layout, rtext, icon='ERROR', width=w) + return; + + if ui_props.asset_type == 'MODEL': + label_multiline(layout, "Uploaded models won't be available in b2.79", icon='ERROR') + if bpy.context.active_object is not None: + draw_panel_model_upload(self, context) + else: + layout.label(text='selet object to upload') + elif ui_props.asset_type == 'SCENE': + draw_panel_scene_upload(self, context) + + elif ui_props.asset_type == 'MATERIAL': + label_multiline(layout, "Uploaded materials won't be available in b2.79", icon='ERROR') + + if bpy.context.active_object is not None and bpy.context.active_object.active_material is not None: + draw_panel_material_upload(self, context) + else: + label_multiline(layout, text='select object with material to upload materials', width=w) + + elif ui_props.asset_type == 'BRUSH': + if context.sculpt_object or context.image_paint_object: + draw_panel_brush_upload(self, context) + else: + layout.label(text='switch to paint or sculpt mode.') + + elif ui_props.down_up == 'RATING': # the poll functions didn't work here, don't know why. + + if ui_props.asset_type == 'MODEL': + # TODO improve poll here to parenting structures + if bpy.context.active_object is not None and bpy.context.active_object.get('asset_data') != None: + ad = bpy.context.active_object.get('asset_data') + layout.label(text=ad['name']) + draw_panel_model_rating(self, context) + if ui_props.asset_type == 'MATERIAL': + if bpy.context.active_object is not None and \ + bpy.context.active_object.active_material is not None and \ + bpy.context.active_object.active_material.blenderkit.asset_base_id != '': + layout.label(text=bpy.context.active_object.active_material.blenderkit.name + ' :') + # noinspection PyCallByClass + draw_panel_material_ratings(self, context) + if ui_props.asset_type == 'BRUSH': + if context.sculpt_object or context.image_paint_object: + props = utils.get_brush_props(context) + if props.asset_base_id != '': + layout.label(text=props.name + ' :') + # noinspection PyCallByClass + draw_panel_brush_ratings(self, context) + if ui_props.asset_type == 'TEXTURE': + layout.label(text='not yet implemented') + + +class SetCategoryOperator(bpy.types.Operator): + """Visit subcategory""" + bl_idname = "view3d.blenderkit_set_category" + bl_label = "BlenderKit Set Active Category" + + category: bpy.props.StringProperty( + name="Category", + description="set this category active", + default="") + + asset_type: bpy.props.StringProperty( + name="Asset Type", + description="asset type", + default="") + + @classmethod + def poll(cls, context): + return True + + def execute(self, context): + acat = bpy.context.window_manager['active_category'][self.asset_type] + if self.category == '': + acat.remove(acat[-1]) + else: + acat.append(self.category) + # we have to write back to wm. Thought this should happen with original list. + bpy.context.window_manager['active_category'][self.asset_type] = acat + return {'FINISHED'} + + +def draw_panel_categories(self, context): + s = context.scene + ui_props = s.blenderkitUI + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + layout = self.layout + # row = layout.row() + # row.prop(ui_props, 'asset_type', expand=True, icon_only=True) + layout.separator() + layout.label(text='Categories') + wm = bpy.context.window_manager + + col = layout.column(align=True) + if wm.get('active_category') is not None: + acat = wm['active_category'][ui_props.asset_type] + if len(acat) > 1: + # we are in subcategory, so draw the parent button + op = col.operator('view3d.blenderkit_set_category', text='...', icon='FILE_PARENT') + op.asset_type = ui_props.asset_type + op.category = '' + cats = categories.get_category(wm['bkit_categories'], cat_path=acat) + # draw freebies only in models parent category + if ui_props.asset_type == 'MODEL' and len(acat) == 1: + op = col.operator('view3d.blenderkit_asset_bar', text='freebies') + op.free_only = True + + for c in cats['children']: + if c['assetCount'] > 0: + row = col.row(align=True) + if len(c['children']) > 0 and c['assetCount'] > 15: + row = row.split(factor=.8, align=True) + # row = split.split() + ctext = '%s (%i)' % (c['name'], c['assetCount']) + op = row.operator('view3d.blenderkit_asset_bar', text=ctext) + op.do_search = True + op.keep_running = True + op.category = c['slug'] + # TODO enable subcategories, now not working due to some bug on server probably + if len(c['children']) > 0 and c['assetCount'] > 15: + # row = row.split() + op = row.operator('view3d.blenderkit_set_category', text='>>') + op.asset_type = ui_props.asset_type + op.category = c['slug'] + # for c1 in c['children']: + # if c1['assetCount']>0: + # row = col.row() + # split = row.split(percentage=.2) + # row = split.split() + # row = split.split() + # ctext = '%s (%i)' % (c1['name'], c1['assetCount']) + # op = row.operator('view3d.blenderkit_search', text=ctext) + # op.category = c1['slug'] + + +class VIEW3D_PT_blenderkit_downloads(Panel): + bl_category = "BlenderKit" + bl_idname = "VIEW3D_PT_blenderkit_downloads" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_label = "Downloads" + + @classmethod + def poll(cls, context): + return len(download.download_threads) > 0 + + def draw(self, context): + layout = self.layout + for threaddata in download.download_threads: + tcom = threaddata[2] + asset_data = threaddata[1] + row = layout.row() + row.label(text=asset_data['name']) + row.label(text=str(int(tcom.progress)) + ' %') + + +classess = ( + SetCategoryOperator, + + VIEW3D_PT_blenderkit_unified, + VIEW3D_PT_blenderkit_model_properties, + VIEW3D_PT_blenderkit_downloads, + +) + + +def register_ui_panels(): + for c in classess: + bpy.utils.register_class(c) + + +def unregister_ui_panels(): + for c in classess: + bpy.utils.unregister_class(c) diff --git a/blenderkit/upload.py b/blenderkit/upload.py new file mode 100644 index 00000000..3be1650a --- /dev/null +++ b/blenderkit/upload.py @@ -0,0 +1,765 @@ +# ##### 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 ##### + +if "bpy" in locals(): + import imp + + imp.reload(asset_inspector) + imp.reload(paths) + imp.reload(utils) + imp.reload(bg_blender) + imp.reload(autothumb) +else: + from blenderkit import asset_inspector, paths, utils, bg_blender, autothumb, version_checker + +import tempfile, os, subprocess, json, re + +import bpy +import requests + +BLENDERKIT_EXPORT_DATA_FILE = "data.json" + +from bpy.props import ( # TODO only keep the ones actually used when cleaning + EnumProperty, + BoolProperty, +) +from bpy.types import ( + Operator, + Panel, + AddonPreferences, + PropertyGroup, + UIList +) + + +def comma2array(text): + commasep = text.split(',') + ar = [] + for i, s in enumerate(commasep): + s = s.strip() + if s != '': + ar.append(s) + return ar + + +def get_app_version(): + ver = bpy.app.version + return '%i.%i.%i' % (ver[0], ver[1], ver[2]) + + +def add_version(data): + app_version = get_app_version() + addon_version = version_checker.get_addon_version() + data["sourceAppName"] = "blender" + data["sourceAppVersion"] = app_version + data["addonVersion"] = addon_version + + +def params_to_dict(inputs, parameters=None): + if parameters == None: + parameters = [] + for k in inputs.keys(): + if type(inputs[k]) == list: + strlist = "" + for idx, s in enumerate(inputs[k]): + strlist += s + if idx < len(inputs[k]) - 1: + strlist += ',' + + value = "%s" % strlist + elif type(inputs[k]) != bool: + value = inputs[k] + else: + value = str(inputs[k]) + parameters.append( + { + "parameterType": k, + "value": value + }) + return parameters + + +def write_to_report(props, text): + props.report = props.report + text + '\n' + + +def get_missing_data_model(props): + props.report = '' + autothumb.update_upload_model_preview(None, None) + + if props.name == '': + write_to_report(props, 'Set model name') + if props.tags == '': + write_to_report(props, 'Write at least 3 tags') + if not props.has_thumbnail: + write_to_report(props, 'Add thumbnail:') + + props.report += props.thumbnail_generating_state + '\n' + if props.work_hours == 0.0: + write_to_report(props, 'Fill in work hours') + if props.engine == 'NONE': + write_to_report(props, 'Set at least one rendering/output engine') + if not any(props.dimensions): + write_to_report(props, 'Run autotags operator or fill in dimensions manually') + + +def get_missing_data_scene(props): + props.report = '' + autothumb.update_upload_model_preview(None, None) + + if props.name == '': + write_to_report(props, 'Set scene name') + if props.tags == '': + write_to_report(props, 'Write at least 3 tags') + if not props.has_thumbnail: + write_to_report(props, 'Add thumbnail:') + + props.report += props.thumbnail_generating_state + '\n' + if props.work_hours == 0.0: + write_to_report(props, 'Fill in work hours') + if props.engine == 'NONE': + write_to_report(props, 'Set at least one rendering/output engine') + + +def get_missing_data_material(props): + props.report = '' + autothumb.update_upload_material_preview(None, None) + if props.name == '': + write_to_report(props, 'Set material name') + if props.tags == '': + write_to_report(props, 'Write at least 3 tags') + if not props.has_thumbnail: + write_to_report(props, 'Add thumbnail:') + props.report += props.thumbnail_generating_state + if props.engine == 'NONE': + write_to_report(props, 'Set rendering/output engine') + + +def get_missing_data_brush(props): + autothumb.update_upload_brush_preview(None, None) + props.report = '' + if props.name == '': + write_to_report(props, 'Set brush name') + if props.tags == '': + write_to_report(props, 'Write at least 3 tags') + if not props.has_thumbnail: + write_to_report(props, 'Add thumbnail:') + props.report += props.thumbnail_generating_state + + +def sub_to_camel(content): + replaced = re.sub(r"_.", + lambda m: m.group(0)[1].upper(), content) + return (replaced) + + +def camel_to_sub(content): + replaced = re.sub(r"[A-Z]", lambda m: '_' + m.group(0).lower(), content) + return replaced + + +def get_upload_data(self, context, asset_type): + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + api_key = user_preferences.api_key + + export_data = { + "type": asset_type, + } + upload_params = {} + if asset_type == 'MODEL': + # Prepare to save the file + mainmodel = utils.get_active_model() + + props = mainmodel.blenderkit + + obs = utils.get_hierarchy(mainmodel) + obnames = [] + for ob in obs: + obnames.append(ob.name) + export_data["models"] = obnames + export_data["thumbnail_path"] = bpy.path.abspath(props.thumbnail) + + eval_path_computing = "bpy.data.objects['%s'].blenderkit.uploading" % mainmodel.name + eval_path_state = "bpy.data.objects['%s'].blenderkit.upload_state" % mainmodel.name + eval_path = "bpy.data.objects['%s']" % mainmodel.name + + engines = [props.engine.lower()] + if props.engine1 != 'NONE': + engines.append(props.engine1.lower()) + if props.engine2 != 'NONE': + engines.append(props.engine2.lower()) + if props.engine3 != 'NONE': + engines.append(props.engine3.lower()) + if props.engine == 'OTHER': + engines.append(props.engine_other.lower()) + + style = props.style.lower() + if style == 'OTHER': + style = props.style_other.lower() + + pl_dict = {'FINISHED': 'finished', 'TEMPLATE': 'template'} + + upload_data = { + "assetType": 'model', + + } + upload_params = { + "productionLevel": props.production_level.lower(), + "model_style": style, + "engines": engines, + "modifiers": comma2array(props.modifiers), + "materials": comma2array(props.materials), + "shaders": comma2array(props.shaders), + "uv": props.uv, + "dimensionX": round(props.dimensions[0], 4), + "dimensionY": round(props.dimensions[1], 4), + "dimensionZ": round(props.dimensions[2], 4), + + "boundBoxMinX": round(props.bbox_min[0], 4), + "boundBoxMinY": round(props.bbox_min[1], 4), + "boundBoxMinZ": round(props.bbox_min[2], 4), + + "boundBoxMaxX": round(props.bbox_max[0], 4), + "boundBoxMaxY": round(props.bbox_max[1], 4), + "boundBoxMaxZ": round(props.bbox_max[2], 4), + + "animated": props.animated, + "rig": props.rig, + "simulation": props.simulation, + "purePbr": props.pbr, + "faceCount": props.face_count, + "faceCountRender": props.face_count_render, + "manifold": props.manifold, + "objectCount": props.object_count, + + # "scene": props.is_scene, + } + if props.use_design_year: + upload_params["designYear"] = props.design_year + if props.condition != 'UNSPECIFIED': + upload_params["condition"] = props.condition.lower() + if props.pbr: + pt = props.pbr_type + pt = pt.lower() + upload_params["pbrType"] = pt + + if props.texture_resolution_max > 0: + upload_params["textureResolutionMax"] = props.texture_resolution_max + upload_params["textureResolutionMin"] = props.texture_resolution_min + if props.mesh_poly_type != 'OTHER': + upload_params["meshPolyType"] = props.mesh_poly_type.lower() # .replace('_',' ') + + optional_params = ['manufacturer', 'designer', 'design_collection', 'design_variant'] + for p in optional_params: + if eval('props.%s' % p) != '': + upload_params[sub_to_camel(p)] = eval('props.%s' % p) + + if asset_type == 'SCENE': + # Prepare to save the file + s = bpy.context.scene + + props = s.blenderkit + + export_data["scene"] = s.name + export_data["thumbnail_path"] = bpy.path.abspath(props.thumbnail) + + eval_path_computing = "bpy.data.scenes['%s'].blenderkit.uploading" % s.name + eval_path_state = "bpy.data.scenes['%s'].blenderkit.upload_state" % s.name + eval_path = "bpy.data.scenes['%s']" % s.name + + engines = [props.engine.lower()] + if props.engine1 != 'NONE': + engines.append(props.engine1.lower()) + if props.engine2 != 'NONE': + engines.append(props.engine2.lower()) + if props.engine3 != 'NONE': + engines.append(props.engine3.lower()) + if props.engine == 'OTHER': + engines.append(props.engine_other.lower()) + + style = props.style.lower() + if style == 'OTHER': + style = props.style_other.lower() + + pl_dict = {'FINISHED': 'finished', 'TEMPLATE': 'template'} + + upload_data = { + "assetType": 'scene', + + } + upload_params = { + "productionLevel": props.production_level.lower(), + "model_style": style, + "engines": engines, + "modifiers": comma2array(props.modifiers), + "materials": comma2array(props.materials), + "shaders": comma2array(props.shaders), + "uv": props.uv, + + "animated": props.animated, + # "simulation": props.simulation, + "purePbr": props.pbr, + "faceCount": 1, # props.face_count, + "faceCountRender": 1, # props.face_count_render, + "objectCount": 1, # props.object_count, + + # "scene": props.is_scene, + } + if props.use_design_year: + upload_params["designYear"] = props.design_year + if props.condition != 'UNSPECIFIED': + upload_params["condition"] = props.condition.lower() + if props.pbr: + pt = props.pbr_type + pt = pt.lower() + upload_params["pbrType"] = pt + + if props.texture_resolution_max > 0: + upload_params["textureResolutionMax"] = props.texture_resolution_max + upload_params["textureResolutionMin"] = props.texture_resolution_min + if props.mesh_poly_type != 'OTHER': + upload_params["meshPolyType"] = props.mesh_poly_type.lower() # .replace('_',' ') + + elif asset_type == 'MATERIAL': + mat = bpy.context.active_object.active_material + props = mat.blenderkit + + # props.name = mat.name + + export_data["material"] = str(mat.name) + export_data["thumbnail_path"] = bpy.path.abspath(props.thumbnail) + # mat analytics happen here, since they don't take up any time... + asset_inspector.check_material(props, mat) + + eval_path_computing = "bpy.data.materials['%s'].blenderkit.uploading" % mat.name + eval_path_state = "bpy.data.materials['%s'].blenderkit.upload_state" % mat.name + eval_path = "bpy.data.materials['%s']" % mat.name + + engine = props.engine + if engine == 'OTHER': + engine = props.engine_other + engine = engine.lower() + style = props.style.lower() + if style == 'OTHER': + style = props.style_other.lower() + + upload_data = { + "assetType": 'material', + + } + + upload_params = { + "material_style": style, + "engine": engine, + "shaders": comma2array(props.shaders), + "uv": props.uv, + "animated": props.animated, + "purePbr": props.pbr, + "textureSizeMeters": props.texture_size_meters, + + } + + if props.pbr: + upload_params["pbrType"] = props.pbr_type.lower() + + if props.texture_resolution_max > 0: + upload_params["textureResolutionMax"] = props.texture_resolution_max + upload_params["textureResolutionMin"] = props.texture_resolution_min + + elif asset_type == 'BRUSH': + brush = utils.get_active_brush() + + props = brush.blenderkit + # props.name = brush.name + + export_data["brush"] = str(brush.name) + export_data["thumbnail_path"] = bpy.path.abspath(brush.icon_filepath) + + eval_path_computing = "bpy.data.brushes['%s'].blenderkit.uploading" % brush.name + eval_path_state = "bpy.data.brushes['%s'].blenderkit.upload_state" % brush.name + eval_path = "bpy.data.brushes['%s']" % brush.name + + # mat analytics happen here, since they don't take up any time... + + brush_type = '' + if bpy.context.sculpt_object is not None: + brush_type = 'sculpt' + + elif bpy.context.image_paint_object: # could be just else, but for future p + brush_type = 'texture_paint' + + upload_params = { + "mode": brush_type, + } + + upload_data = { + "assetType": 'brush', + } + + elif asset_type == 'TEXTURE': + style = props.style + if style == 'OTHER': + style = props.style_other + + upload_data = { + "assetType": 'texture', + + } + upload_params = { + "style": style, + "animated": props.animated, + "purePbr": props.pbr, + "resolution": props.resolution, + } + if props.pbr: + pt = props.pbr_type + pt = pt.lower() + upload_data["pbrType"] = pt + + add_version(upload_data) + + upload_data["name"] = props.name + upload_data["description"] = props.description + upload_data["tags"] = comma2array(props.tags) + if props.category == '': + upload_data["category"] = asset_type.lower() + else: + upload_data["category"] = props.category + if props.subcategory != '': + upload_data["category"] = props.subcategory + upload_data["license"] = props.license + upload_data["isFree"] = props.is_free + upload_data["token"] = user_preferences.api_key + + if props.asset_base_id != '': + upload_data['assetBaseId'] = props.asset_base_id + upload_data['id'] = props.id + + upload_data['parameters'] = upload_params + + return export_data, upload_data, eval_path_computing, eval_path_state, eval_path, props + + +def mark_for_validation(self, context, asset_type): + props = utils.get_upload_props() + props.upload_state = 'marking for validation' + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + + upload_data = { + "verificationStatus": "ready" + } + + url = paths.get_bkit_url() + 'assets/' + headers = {"accept": "application/json", "Authorization": "Bearer %s" % user_preferences.api_key} + url += props.id + '/' + + try: + r = requests.patch(url, json=upload_data, headers=headers, verify=True) # files = files, + props.upload_state = 'marked for validation' + except requests.exceptions.RequestException as e: + props.upload_state = str(e) + props.uploading = False + return {'CANCELLED'} + return {'FINISHED'} + + +def get_upload_location(props): + scene = bpy.context.scene + ui_props = scene.blenderkitUI + if ui_props.asset_type == 'MODEL': + if bpy.context.active_object is not None: + ob = utils.get_active_model() + return ob.location + if ui_props.asset_type == 'SCENE': + return None + elif ui_props.asset_type == 'MATERIAL': + if bpy.context.active_object is not None and bpy.context.active_object.active_material is not None: + return bpy.context.active_object.location + elif ui_props.asset_type == 'TEXTURE': + return None + elif ui_props.asset_type == 'BRUSH': + return None + return None + + +def start_upload(self, context, asset_type, as_new, metadata_only): + props = utils.get_upload_props() + location = get_upload_location(props) + props.upload_state = 'preparing upload' + # do this for fixing long tags in some upload cases + props.tags = props.tags[:] + + props.name = props.name.strip() + # TODO move this to separate function + # check for missing metadata + if asset_type == 'MODEL': + get_missing_data_model(props) + if asset_type == 'SCENE': + get_missing_data_scene(props) + elif asset_type == 'MATERIAL': + get_missing_data_material(props) + elif asset_type == 'BRUSH': + get_missing_data_brush(props) + + if props.report != '': + self.report({'ERROR_INVALID_INPUT'}, props.report) + return {'CANCELLED'} + if as_new: + props.asset_base_id = '' + props.id = '' + export_data, upload_data, eval_path_computing, eval_path_state, eval_path, props = get_upload_data(self, context, + asset_type) + # utils.pprint(upload_data) + upload_data['parameters'] = params_to_dict( + upload_data['parameters']) # weird array conversion only for upload, not for tooltips. + + binary_path = bpy.app.binary_path + script_path = os.path.dirname(os.path.realpath(__file__)) + basename, ext = os.path.splitext(bpy.data.filepath) + # if not basename: + # basename = os.path.join(basename, "temp") + if not ext: + ext = ".blend" + tempdir = tempfile.mkdtemp() + source_filepath = os.path.join(tempdir, "export_blenderkit" + ext) + clean_file_path = paths.get_clean_filepath() + data = { + 'clean_file_path': clean_file_path, + 'source_filepath': source_filepath, + 'temp_dir': tempdir, + 'export_data': export_data, + 'upload_data': upload_data, + 'debug_value': bpy.app.debug_value, + } + datafile = os.path.join(tempdir, BLENDERKIT_EXPORT_DATA_FILE) + + # check if thumbnail exists: + + if not os.path.exists(export_data["thumbnail_path"]): + props.upload_state = 'Thumbnail not found' + props.uploading = False + return {'CANCELLED'} + + # first upload metadata to server, so it can be saved inside the current file + url = paths.get_bkit_url() + 'assets/' + headers = {"accept": "application/json", "Authorization": "Bearer %s" % upload_data['token']} + # upload_data['license'] = 'ovejajojo' + json_metadata = upload_data # json.dumps(upload_data, ensure_ascii=False).encode('utf8') + global reports + if props.asset_base_id == '': + try: + r = requests.post(url, json=json_metadata, headers=headers, verify=True) # files = files, + props.upload_state = 'uploaded metadata' + except requests.exceptions.RequestException as e: + print(e) + props.upload_state = str(e) + props.uploading = False + return {'CANCELLED'} + + else: + url += props.id + '/' + try: + if not metadata_only: + json_metadata["verificationStatus"] = "uploading" + r = requests.put(url, json=json_metadata, headers=headers, verify=True) # files = files, + props.upload_state = 'uploaded metadata' + # parse the reqest + # print('uploaded metadata') + # print(r.text) + except requests.exceptions.RequestException as e: + print(e) + props.upload_state = str(e) + props.uploading = False + return {'CANCELLED'} + + # props.upload_state = 'step 1' + if metadata_only: + props.uploading = False + + return {'FINISHED'} + try: + rj = r.json() + if props.asset_base_id == '': + props.asset_base_id = rj['assetBaseId'] + props.id = rj['id'] + + upload_data['assetBaseId'] = props.asset_base_id + upload_data['id'] = props.id + + bpy.ops.wm.save_mainfile() + # fa + props.uploading = True + # save a copy of actual scene but don't interfere with the users models + bpy.ops.wm.save_as_mainfile(filepath=source_filepath, compress=False, copy=True) + + with open(datafile, 'w') as s: + json.dump(data, s) + + proc = subprocess.Popen([ + binary_path, + "--background", + "-noaudio", + clean_file_path, + "--python", os.path.join(script_path, "upload_bg.py"), + "--", datafile # ,filepath, tempdir + ], bufsize=5000, stdout=subprocess.PIPE, stdin=subprocess.PIPE) + + bg_blender.add_bg_process(eval_path_computing=eval_path_computing, eval_path_state=eval_path_state, + eval_path=eval_path, process_type='UPLOAD', process=proc, location=location) + + except Exception as e: + props.upload_state = str(e) + props.uploading = False + return {'CANCELLED'} + + return {'FINISHED'} + + +asset_types = ( + ('MODEL', 'Model', 'set of objects'), + ('SCENE', 'Scene', 'scene'), + ('MATERIAL', 'Material', 'any .blend Material'), + ('TEXTURE', 'Texture', 'a texture, or texture set'), + ('BRUSH', 'Brush', 'brush, can be any type of blender brush'), + ('ADDON', 'Addon', 'addnon'), +) + + +class ModelUploadOperator(Operator): + """Tooltip""" + bl_idname = "object.blenderkit_upload" + bl_description = "Upload or re-upload asset + thumbnail + metadata" + + bl_label = "BlenderKit asset upload" + + # type of upload - model, material, textures, e.t.c. + asset_type: EnumProperty( + name="Type", + items=asset_types, + description="Type of upload", + default="MODEL", + ) + + as_new: BoolProperty( + name="upload as new", + description="delets asset id and uploads as new file", + default=False, + options={'SKIP_SAVE'} + ) + + metadata_only: BoolProperty( + name="upload metadata", + description="update only metadata", + default=False, + options={'SKIP_SAVE'} + ) + + @classmethod + def poll(cls, context): + return bpy.context.active_object is not None + + def execute(self, context): + bpy.ops.object.blenderkit_auto_tags() + props = utils.get_upload_props() + + # in case of name change, we have to reupload everything, since the name is stored in blender file, + # and is used for linking to scene + metadata_only = self.metadata_only + if props.name_changed: + print('has to reupload whole data, name has changed.') + self.metadata_only = False + props.name_changed = False + + result = start_upload(self, context, self.asset_type, self.as_new, self.metadata_only) + + return result + + def draw(self, context): + layout = self.layout + layout.label(text="Really upload as new? ") + layout.label(text="Do this only when you create a new asset from an old one.") + layout.label(text="For updates of thumbnail or model use reupload.") + + def invoke(self, context, event): + if self.as_new: + return context.window_manager.invoke_props_dialog(self) + else: + return self.execute(context) + + +class ModelMarkForValidation(Operator): + """Tooltip""" + bl_idname = "object.blenderkit_mark_for_validation" + bl_description = "After this, model can be validated by our validators and \n then be part of the public database" + + bl_label = "Mark for validation" + + # type of upload - model, material, textures, e.t.c. + asset_type: EnumProperty( + name="type", + items=asset_types, + description="Type of upload", + default="MODEL", + ) + + @classmethod + def poll(cls, context): + props = utils.get_upload_props() + return bpy.context.active_object is not None and props.asset_base_id != '' + + def execute(self, context): + result = mark_for_validation(self, context, self.asset_type) + + return result + + +# TODO this is for upldating by creators, if they edit metadata on server or want to upload from older file version. +# class GetMetadataFromServer(Operator): +# """Tooltip""" +# bl_idname = "object.blenderkit_get_from_server" +# bl_description ="After this, model can be validated by our validators and \n then be part of the public database" +# +# bl_label = "Mark for validation" +# +# # type of upload - model, material, textures, e.t.c. +# asset_type : EnumProperty( +# name="type", +# items=asset_types, +# description="Type of upload", +# default="MODEL", +# ) +# +# @classmethod +# def poll(cls, context): +# props = utils.get_upload_props() +# return bpy.context.active_object is not None and props.asset_base_id != '' +# +# def execute(self, context): +# result = mark_for_validation(self, context, self.asset_type) +# +# return result + + +def register_upload(): + bpy.utils.register_class(ModelUploadOperator) + bpy.utils.register_class(ModelMarkForValidation) + + +def unregister_upload(): + bpy.utils.unregister_class(ModelUploadOperator) + bpy.utils.unregister_class(ModelMarkForValidation) diff --git a/blenderkit/upload_bg.py b/blenderkit/upload_bg.py new file mode 100644 index 00000000..60ee4690 --- /dev/null +++ b/blenderkit/upload_bg.py @@ -0,0 +1,199 @@ +# ##### 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 sys, json, os, time +import requests +import logging + +import bpy +import addon_utils +from blenderkit import paths, append_link, bg_blender + +BLENDERKIT_EXPORT_DATA = sys.argv[-1] + + +def start_logging(): + logging.basicConfig() + logging.getLogger().setLevel(logging.DEBUG) + requests_log = logging.getLogger("requests.packages.urllib3") + requests_log.setLevel(logging.DEBUG) + requests_log.propagate = True + + +def print_gap(): + print('\n\n\n\n') + + +class upload_in_chunks(object): + def __init__(self, filename, chunksize=1 << 13, report_name='file'): + self.filename = filename + self.chunksize = chunksize + self.totalsize = os.path.getsize(filename) + self.readsofar = 0 + self.report_name = report_name + + def __iter__(self): + with open(self.filename, 'rb') as file: + while True: + data = file.read(self.chunksize) + if not data: + sys.stderr.write("\n") + break + self.readsofar += len(data) + percent = self.readsofar * 1e2 / self.totalsize + bg_blender.progress('uploading %s' % self.report_name, percent) + # sys.stderr.write("\r{percent:3.0f}%".format(percent=percent)) + yield data + + def __len__(self): + return self.totalsize + + +def upload_files(filepath, upload_data, files): + headers = {"accept": "application/json", "Authorization": "Bearer %s" % upload_data['token']} + version_id = upload_data['id'] + for f in files: + bg_blender.progress('uploading %s' % f['type']) + upload_info = { + 'assetId': version_id, + 'fileType': f['type'], + 'fileIndex': f['index'], + 'originalFilename': os.path.basename(f['file_path']) + } + upload_create_url = paths.get_bkit_url() + 'uploads/' + upload = requests.post(upload_create_url, json=upload_info, headers=headers, verify=True) + upload = upload.json() + + upheaders = { + "accept": "application/json", + "Authorization": "Bearer %s" % upload_data['token'], + "Content-Type": "multipart/form-data", + "Content-Disposition": 'form-data; name="file"; filename=%s' % f['file_path'] + + } + chunk_size = 1024 * 256 + + # file gets uploaded here: + uploaded = False + # s3 upload is now the only option + for a in range(0, 20): + if not uploaded: + try: + upload_response = requests.put(upload['s3UploadUrl'], + data=upload_in_chunks(f['file_path'], chunk_size, f['type']), + stream=True, verify=True) + print('upload response') + print(upload_response.text) + uploaded = True + except Exception as e: + bg_blender.progress('Upload %s failed, retrying' % f['type']) + time.sleep(1) + + # confirm single file upload to bkit server + upload_done_url = paths.get_bkit_url() + 'uploads_s3/' + upload['id'] + '/upload-file/' + upload_response = requests.post(upload_done_url, headers=headers, verify=True) + + bg_blender.progress('finished uploading') + + return {'FINISHED'} + + +if __name__ == "__main__": + + bpy.data.scenes.new('upload') + for s in bpy.data.scenes: + if s.name != 'upload': + bpy.data.scenes.remove(s) + try: + # bg_blender.progress('preparing scene') + bg_blender.progress('preparing scene - link objects') + with open(BLENDERKIT_EXPORT_DATA, 'r') as s: + data = json.load(s) + + bpy.app.debug_value = data.get('debug_value', 0) + export_data = data['export_data'] + upload_data = data['upload_data'] + + if export_data['type'] == 'MODEL': + obnames = export_data['models'] + main_source, allobs = append_link.append_objects(file_name=data['source_filepath'], + obnames=obnames, + rotation=(0, 0, 0)) + g = bpy.data.collections.new(upload_data['name']) + for o in allobs: + g.objects.link(o) + bpy.context.scene.collection.children.link(g) + if export_data['type'] == 'SCENE': + sname = export_data['scene'] + main_source = append_link.append_scene(file_name=data['source_filepath'], + scenename=sname) + bpy.data.scenes.remove(bpy.data.scenes['upload']) + main_source.name = sname + elif export_data['type'] == 'MATERIAL': + matname = export_data['material'] + main_source = append_link.append_material(file_name=data['source_filepath'], matname=matname) + + elif export_data['type'] == 'BRUSH': + brushname = export_data['brush'] + main_source = append_link.append_brush(file_name=data['source_filepath'], brushname=brushname) + + bpy.ops.file.pack_all() + + # TODO fetch asset_id here + asset_id = main_source.blenderkit.asset_base_id + main_source.blenderkit.uploading = False + + fpath = os.path.join(data['temp_dir'], asset_id + '.blend') + + bpy.ops.wm.save_as_mainfile(filepath=fpath, compress=True, copy=False) + os.remove(data['source_filepath']) + + bg_blender.progress('preparing scene - open files') + + files = [{ + "type": "thumbnail", + "index": 0, + "file_path": export_data["thumbnail_path"] + }, { + "type": "blend", + "index": 0, + "file_path": fpath + }] + + bg_blender.progress('uploading') + + upload_files(fpath, upload_data, files) + + # mark on server as uploaded + confirm_data = { + "verificationStatus": "uploaded" + } + + url = paths.get_bkit_url() + 'assets/' + headers = {"accept": "application/json", "Authorization": "Bearer %s" % upload_data['token']} + url += upload_data["id"] + '/' + + r = requests.patch(url, json=confirm_data, headers=headers, verify=True) # files = files, + + bg_blender.progress('upload finished successfully') + + + except Exception as e: + print(e) + bg_blender.progress(e) + sys.exit(1) diff --git a/blenderkit/utils.py b/blenderkit/utils.py new file mode 100644 index 00000000..39c55f33 --- /dev/null +++ b/blenderkit/utils.py @@ -0,0 +1,415 @@ +# ##### 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 ##### + +if "bpy" in locals(): + import imp + + imp.reload(paths) +else: + from blenderkit import paths, categories +import bpy +from mathutils import Vector +import json +import os +import requests + + +def activate(ob): + bpy.ops.object.select_all(action='DESELECT') + ob.select_set(True) + bpy.context.view_layer.objects.active = ob + + +def selection_get(): + aob = bpy.context.active_object + selobs = bpy.context.selected_objects + return (aob, selobs) + + +def selection_set(sel): + bpy.ops.object.select_all(action='DESELECT') + bpy.context.view_layer.objects.active = sel[0] + for ob in sel[1]: + ob.select_set(True) + + +def get_active_model(): + if hasattr(bpy.context, 'active_object'): + ob = bpy.context.active_object + while ob.parent is not None: + ob = ob.parent + return ob + return None + + +def get_selected_models(): + obs = bpy.context.selected_objects[:] + done = {} + parents = [] + for ob in obs: + if ob not in done: + while ob.parent is not None and ob not in done: + done[ob] = True + ob = ob.parent + + if ob not in parents and ob not in done: + if ob.blenderkit.name != '': + parents.append(ob) + done[ob] = True + return parents + + +def get_search_props(): + scene = bpy.context.scene + uiprops = scene.blenderkitUI + props = None + if uiprops.asset_type == 'MODEL': + if not hasattr(scene, 'blenderkit_models'): + return; + props = scene.blenderkit_models + if uiprops.asset_type == 'SCENE': + if not hasattr(scene, 'blenderkit_scene'): + return; + props = scene.blenderkit_scene + if uiprops.asset_type == 'MATERIAL': + if not hasattr(scene, 'blenderkit_mat'): + return; + props = scene.blenderkit_mat + + if uiprops.asset_type == 'TEXTURE': + if not hasattr(scene, 'blenderkit_tex'): + return; + # props = scene.blenderkit_tex + + if uiprops.asset_type == 'BRUSH': + if not hasattr(scene, 'blenderkit_brush'): + return; + props = scene.blenderkit_brush + return props + + +def get_active_asset(): + scene = bpy.context.scene + ui_props = scene.blenderkitUI + if ui_props.asset_type == 'MODEL': + if bpy.context.active_object is not None: + ob = get_active_model() + return ob + if ui_props.asset_type == 'SCENE': + return bpy.context.scene + + elif ui_props.asset_type == 'MATERIAL': + if bpy.context.active_object is not None and bpy.context.active_object.active_material is not None: + return bpy.context.active_object.active_material + elif ui_props.asset_type == 'TEXTURE': + return None + elif ui_props.asset_type == 'BRUSH': + b = get_active_brush() + if b is not None: + return b + return None + + +def get_upload_props(): + scene = bpy.context.scene + ui_props = scene.blenderkitUI + if ui_props.asset_type == 'MODEL': + if bpy.context.active_object is not None: + ob = get_active_model() + return ob.blenderkit + if ui_props.asset_type == 'SCENE': + s = bpy.context.scene + return s.blenderkit + elif ui_props.asset_type == 'MATERIAL': + if bpy.context.active_object is not None and bpy.context.active_object.active_material is not None: + return bpy.context.active_object.active_material.blenderkit + elif ui_props.asset_type == 'TEXTURE': + return None + elif ui_props.asset_type == 'BRUSH': + b = get_active_brush() + if b is not None: + return b.blenderkit + return None + + +def previmg_name(index, fullsize=False): + if not fullsize: + return '.bkit_preview_' + str(index).zfill(2) + else: + return '.bkit_preview_full_' + str(index).zfill(2) + + +def get_active_brush(): + context = bpy.context + brush = None + if context.sculpt_object: + brush = context.tool_settings.sculpt.brush + elif context.image_paint_object: # could be just else, but for future possible more types... + brush = context.tool_settings.image_paint.brush + return brush + + +def load_prefs(): + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + # if user_preferences.api_key == '': + fpath = paths.BLENDERKIT_SETTINGS_FILENAME + if os.path.exists(fpath): + with open(fpath, 'r') as s: + prefs = json.load(s) + user_preferences.api_key = prefs['API_key'] + user_preferences.global_dir = prefs['global_dir'] + + +def save_prefs(self, context): + user_preferences = bpy.context.preferences.addons['blenderkit'].preferences + if user_preferences.api_key != '': + prefs = { + 'API_key': user_preferences.api_key, + 'global_dir': user_preferences.global_dir, + } + # user_preferences.api_key = user_preferences.api_key.strip() + fpath = paths.BLENDERKIT_SETTINGS_FILENAME + f = open(fpath, 'w') + with open(fpath, 'w') as s: + json.dump(prefs, s) + + +def load_categories(): + categories.copy_categories() + tempdir = paths.get_temp_dir() + categories_filepath = os.path.join(tempdir, 'categories.json') + + wm = bpy.context.window_manager + with open(categories_filepath, 'r') as catfile: + wm['bkit_categories'] = json.load(catfile) + + wm['active_category'] = { + 'MODEL': ['model'], + 'SCENE': ['scene'], + 'MATERIAL': ['material'], + 'BRUSH': ['brush'], + } + + +def get_hidden_image(tpath, bdata_name, force_reload=False): + hidden_name = '.%s' % bdata_name + img = bpy.data.images.get(hidden_name) + + if tpath.startswith('//'): + tpath = bpy.path.abspath(tpath) + + gap = '\n\n\n' + en = '\n' + if img == None or (img.filepath != tpath): + if tpath.startswith('//'): + tpath = bpy.path.abspath(tpath) + if not os.path.exists(tpath) or tpath == '': + tpath = paths.get_addon_thumbnail_path('thumbnail_notready.jpg') + + if img is None: + img = bpy.data.images.load(tpath) + img.name = hidden_name + else: + if img.filepath != tpath: + if img.packed_file is not None: + img.unpack(method='USE_ORIGINAL') + + img.filepath = tpath + img.reload() + elif force_reload: + if img.packed_file is not None: + img.unpack(method='USE_ORIGINAL') + img.reload() + return img + + +def get_thumbnail(name): + p = paths.get_addon_thumbnail_path(name) + name = '.%s' % name + img = bpy.data.images.get(name) + if img == None: + img = bpy.data.images.load(p) + img.name = name + img.name = name + + return img + + +def get_brush_props(context): + brush = get_active_brush() + if brush is not None: + return brush.blenderkit + return None + + +def pprint(data): + print(json.dumps(data, indent=4, sort_keys=True)) + + +def get_hierarchy(ob): + obs = [] + doobs = [ob] + while len(doobs) > 0: + o = doobs.pop() + doobs.extend(o.children) + obs.append(o) + return obs + + +def get_bounds_snappable(obs, use_modifiers=False): + # progress('getting bounds of object(s)') + parent = obs[0] + while parent.parent is not None: + parent = parent.parent + maxx = maxy = maxz = -10000000 + minx = miny = minz = 10000000 + + s = bpy.context.scene + + obcount = 0 # calculates the mesh obs. Good for non-mesh objects + matrix_parent = parent.matrix_world + for ob in obs: + # bb=ob.bound_box + mw = ob.matrix_world + subp = ob.parent + # while parent.parent is not None: + # mw = + + if ob.type == 'MESH' or ob.type == 'CURVE': + # If to_mesh() works we can use it on curves and any other ob type almost. + # disabled to_mesh for 2.8 by now, not wanting to use dependency graph yet. + mesh = ob.to_mesh(depsgraph=bpy.context.depsgraph, apply_modifiers=True, calc_undeformed=False) + + # to_mesh(context.depsgraph, apply_modifiers=self.applyModifiers, calc_undeformed=False) + obcount += 1 + for c in mesh.vertices: + coord = c.co + parent_coord = matrix_parent.inverted() @ mw @ Vector( + (coord[0], coord[1], coord[2])) # copy this when it works below. + minx = min(minx, parent_coord.x) + miny = min(miny, parent_coord.y) + minz = min(minz, parent_coord.z) + maxx = max(maxx, parent_coord.x) + maxy = max(maxy, parent_coord.y) + maxz = max(maxz, parent_coord.z) + # bpy.data.meshes.remove(mesh) + + if obcount == 0: + minx, miny, minz, maxx, maxy, maxz = 0, 0, 0, 0, 0, 0 + + minx *= parent.scale.x + maxx *= parent.scale.x + miny *= parent.scale.y + maxy *= parent.scale.y + minz *= parent.scale.z + maxz *= parent.scale.z + + return minx, miny, minz, maxx, maxy, maxz + + +def get_bounds_worldspace(obs, use_modifiers=False): + # progress('getting bounds of object(s)') + s = bpy.context.scene + maxx = maxy = maxz = -10000000 + minx = miny = minz = 10000000 + obcount = 0 # calculates the mesh obs. Good for non-mesh objects + for ob in obs: + # bb=ob.bound_box + mw = ob.matrix_world + if ob.type == 'MESH' or ob.type == 'CURVE': + mesh = ob.to_mesh(depsgraph=bpy.context.depsgraph, apply_modifiers=True, calc_undeformed=False) + obcount += 1 + for c in mesh.vertices: + coord = c.co + world_coord = mw @ Vector((coord[0], coord[1], coord[2])) + minx = min(minx, world_coord.x) + miny = min(miny, world_coord.y) + minz = min(minz, world_coord.z) + maxx = max(maxx, world_coord.x) + maxy = max(maxy, world_coord.y) + maxz = max(maxz, world_coord.z) + + if obcount == 0: + minx, miny, minz, maxx, maxy, maxz = 0, 0, 0, 0, 0, 0 + return minx, miny, minz, maxx, maxy, maxz + + +def is_linked_asset(ob): + return ob.get('asset_data') and ob.instance_collection != None + + +def get_dimensions(obs): + minx, miny, minz, maxx, maxy, maxz = get_bounds_snappable(obs) + bbmin = Vector((minx, miny, minz)) + bbmax = Vector((maxx, maxy, maxz)) + dim = Vector((maxx - minx, maxy - miny, maxz - minz)) + return dim, bbmin, bbmax + + +def requests_post_thread(url, json, headers): + r = requests.post(url, json=json, verify=True, headers=headers) + + +# map uv cubic and switch of auto tex space and set it to 1,1,1 +def automap(target_object=None, target_slot=None, tex_size=1, bg_exception=False): + from blenderkit import bg_blender as bg + s = bpy.context.scene + mat_props = s.blenderkit_mat + if mat_props.automap: + tob = bpy.data.objects[target_object] + # only automap mesh models + if tob.type == 'MESH': + actob = bpy.context.active_object + bpy.context.view_layer.objects.active = tob + + # auto tex space + if tob.data.use_auto_texspace: + tob.data.use_auto_texspace = False + + tob.data.texspace_size = (1, 1, 1) + + if 'automap' not in tob.data.uv_layers: + bpy.ops.mesh.uv_texture_add() + uvl = tob.data.uv_layers[-1] + uvl.name = 'automap' + + # TODO limit this to active material + # tob.data.uv_textures['automap'].active = True + + scale = tob.scale.copy() + + if target_slot is not None: + tob.active_material_index = target_slot + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='DESELECT') + + # this exception is just for a 2.8 background thunmbnailer crash, can be removed when material slot select works... + if bg_exception: + bpy.ops.mesh.select_all(action='SELECT') + else: + bpy.ops.object.material_slot_select() + + scale = (scale.x + scale.y + scale.z) / 3.0 + bpy.ops.uv.cube_project( + cube_size=scale * 2.0 / (tex_size), + correct_aspect=False) # it's 2.0 because blender can't tell size of a cube :) + bpy.ops.object.editmode_toggle() + tob.data.uv_layers.active = tob.data.uv_layers['automap'] + # tob.data.uv_textures["automap"].active_render = True + + bpy.context.view_layer.objects.active = actob diff --git a/blenderkit/version_checker.py b/blenderkit/version_checker.py new file mode 100644 index 00000000..ad5e23a5 --- /dev/null +++ b/blenderkit/version_checker.py @@ -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 requests, os, json, threading +from blenderkit import paths + + +def get_addon_version(): + import blenderkit + ver = blenderkit.bl_info['version'] + return '%i.%i.%i' % (ver[0], ver[1], ver[2]) + + +def check_version(url, api_key, module): + headers = { + "accept": "application/json", + "Authorization": "Bearer %s" % api_key} + + print('checking online version of module %s' % str(module.bl_info['name'])) + try: + r = requests.get(url, headers=headers) + data = r.json() + ver_online = { + 'addonVersion2.8': data['addonVersion'] + } + tempdir = paths.get_temp_dir() + + ver_filepath = os.path.join(tempdir, 'addon_version.json') + with open(ver_filepath, 'w') as s: + json.dump(ver_online, s, indent=4) + except: + print("couldn't check online for version updates") + + +def compare_versions(module): + try: + ver_local = module.bl_info['version'] + ver_local_float = ver_local[0] + .01 * ver_local[1] + .0001 * ver_local[2] + + tempdir = paths.get_temp_dir() + ver_filepath = os.path.join(tempdir, 'addon_version.json') + with open(ver_filepath, 'r') as s: + data = json.load(s) + + ver_online = data['addonVersion2.8'].split('.') + ver_online_float = int(ver_online[0]) + .01 * int(ver_online[1]) + .0001 * int(ver_online[2]) + + print('versions: installed-%s, online-%s' % (str(ver_local_float), str(ver_online_float))) + if ver_online_float > ver_local_float: + return True + except: + print("couldn't compare addon versions") + return False + + +def check_version_thread(url, API_key, module): + thread = threading.Thread(target=check_version, args=([url, API_key, module]), daemon=True) + thread.start() |