Compare commits

..

31 Commits

Author SHA1 Message Date
b9caecfce9 Removed some unused code 2016-07-26 16:02:15 +02:00
4ce8db88c6 Tagged version as 1.4.0 2016-07-26 15:30:23 +02:00
dfff0cb55b Bumped pillarsdk requirement to 1.5.0 2016-07-26 15:30:16 +02:00
1a515bfbda Texture browser: removed the blue line :( 2016-07-22 18:25:24 +02:00
a47dfa8f32 Texture browser: UI polish for HDRi variation selector 2016-07-22 18:25:20 +02:00
8890ad5421 Texture browser: storing desired HDRi variation on image, not window mgr
Also added an HDRi panel in the node properties.
2016-07-22 18:06:13 +02:00
f0d42ed2cc Texture browser: on click on HDRi, always just download the smallest image.
The larger ones can be downloaded later from the GUI.
The addon assumes that the first image in the node.properties.files list is
the smallest one. This can be ensured on a per-project basis by running
'manage.py hdri_sort {project URL}' on the Pillar server.
2016-07-22 17:49:29 +02:00
76ca59251b Texture browser: nicer handling of still-loading menu items. 2016-07-22 17:47:37 +02:00
b33ec74347 Texture browser: Use node name as file name 2016-07-22 17:47:37 +02:00
b5e33c52c1 Texture browser: simplified HDRi replacing.
It now just loads a new image into the existing image datablock.
2016-07-22 17:47:37 +02:00
8b56918989 Texture browser: HDRi variation selector now defaults to variation of the current image. 2016-07-22 17:47:31 +02:00
99257bd88b Added HDRi variation swap operator.
The variation/resolution selector isn't final yet.
2016-07-22 15:39:08 +02:00
c2363d248b Settings sync: solved etag mismatch issue.
This was caused by doing a cached request, which would always return
the etag of the previous request.
2016-07-22 12:57:15 +02:00
3776246d70 Texture browser: load images with relative path if needed.
If the local texture path of the current scene is relative, the image
will also be stored in a relative path.
2016-07-22 12:56:37 +02:00
9bc8c30443 Moved invoke-calling execute function to AsyncModalOperatorMixin
because this is what you'd generally want from an async operator
2016-07-22 12:48:50 +02:00
56b622a723 Texture browser: save Node document with downloaded image. 2016-07-21 16:25:58 +02:00
8edf9c7428 Texture browser: Don't show spinner for HDRi files 2016-07-21 14:09:52 +02:00
10bf3e62ec Marked as beta release in setup.py 2016-07-21 12:11:02 +02:00
3ec1a3d26d Made HRDi browsing more efficient.
It now uses the thumbnail of the node for each file, instead of trying
to download each file's thumbnail individually.
2016-07-21 12:10:29 +02:00
3ce89ad5f4 Show file size for HDRi files. 2016-07-21 11:17:53 +02:00
7cf858855e PEP8 formatting 2016-07-21 11:03:30 +02:00
a10b4a804c Added support for HDRi nodes.
These nodes are like textures, except that here the user should choose
which variation to download (instead of downloading them all).
2016-07-21 11:03:23 +02:00
514968de40 Texture browser: set downloaded image as active in image editor.
If the current context is the image editor, that is.
2016-07-20 17:08:57 +02:00
c73dce169f Added a panel that shows custom properties in the image editor. 2016-07-20 16:54:06 +02:00
369e082880 Added GPL License block to the top of each .py file. 2016-07-20 16:32:01 +02:00
6cd9cb1713 Texture browser: clicking on HDRi node no longer causes exception.
The browser still downloads all HDRi files, though.
2016-07-20 16:17:48 +02:00
37f701edaf Added texture browser to the image menu.
The Ctrl+Alt+Shift+A shortcut still works everywhere, but now it's also
easy to find in the GUI.
2016-07-20 16:09:56 +02:00
b04f9adb40 Texture browser: Don't use file name as menu item label.
Just using the node name is clearer, as it only depends on the node, and
no longer on the linked files themselves. This also makes it easier to
get compatible with HDRi nodes (as those files won't be named
"{name}-{maptype}".
2016-07-20 16:02:56 +02:00
70a0aba10a Allow browsing group_hdri nodes.
Nodes of type 'hdri' don't work well yet.
2016-07-20 15:58:09 +02:00
f6d05c4c84 Bumped version to 1.4.0 (otherwise we don't get HDRi projects from Cloud) 2016-07-20 14:27:00 +02:00
8e9d62b5c5 Include addon version in all Pillar HTTP requests 2016-07-20 14:26:36 +02:00
14 changed files with 630 additions and 80 deletions

View File

@@ -1,4 +1,21 @@
#!/bin/bash
# ##### 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 #####
cd $(dirname $(readlink -f $0))

View File

@@ -21,7 +21,7 @@
bl_info = {
'name': 'Blender Cloud',
'author': 'Sybren A. Stüvel and Francesco Siddi',
'version': (1, 3, 3),
'version': (1, 4, 0),
'blender': (2, 77, 0),
'location': 'Addon Preferences panel, and Ctrl+Shift+Alt+A anywhere for texture browser',
'description': 'Texture library browser and Blender Sync. Requires the Blender ID addon '
@@ -72,6 +72,7 @@ def register():
reload_mod('blendfile')
reload_mod('home_project')
reload_mod('utils')
blender = reload_mod('blender')
async_loop = reload_mod('async_loop')

View File

@@ -1,3 +1,21 @@
# ##### 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 #####
"""Manages the asyncio loop."""
import asyncio
@@ -166,6 +184,9 @@ class AsyncModalOperatorMixin:
self.timer = context.window_manager.event_timer_add(1 / 15, context.window)
return {'RUNNING_MODAL'}
def execute(self, context):
return self.invoke(context, None)
def modal(self, context, event):
task = self.async_task

View File

@@ -1,3 +1,21 @@
# ##### 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 #####
"""Blender-specific code.
Separated from __init__.py so that we can import & run from non-Blender environments.
@@ -9,6 +27,7 @@ import os.path
import bpy
from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty
import rna_prop_ui
from . import pillar
@@ -285,6 +304,17 @@ class PILLAR_OT_subscribe(Operator):
return {'FINISHED'}
class PILLAR_PT_image_custom_properties(rna_prop_ui.PropertyPanel, bpy.types.Panel):
"""Shows custom properties in the image editor."""
bl_space_type = 'IMAGE_EDITOR'
bl_region_type = 'UI'
bl_label = 'Custom Properties'
_context_path = 'edit_image'
_property_type = bpy.types.Image
def preferences() -> BlenderCloudPreferences:
return bpy.context.user_preferences.addons[ADDON_NAME].preferences
@@ -327,6 +357,7 @@ def register():
bpy.utils.register_class(PillarCredentialsUpdate)
bpy.utils.register_class(SyncStatusProperties)
bpy.utils.register_class(PILLAR_OT_subscribe)
bpy.utils.register_class(PILLAR_PT_image_custom_properties)
addon_prefs = preferences()
@@ -358,6 +389,7 @@ def unregister():
bpy.utils.unregister_class(BlenderCloudPreferences)
bpy.utils.unregister_class(SyncStatusProperties)
bpy.utils.unregister_class(PILLAR_OT_subscribe)
bpy.utils.unregister_class(PILLAR_PT_image_custom_properties)
del WindowManager.last_blender_cloud_location
del WindowManager.blender_sync_status

View File

@@ -1,3 +1,21 @@
# ##### 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 #####
"""HTTP Cache management.
This module configures a cached session for the Requests package.

View File

@@ -1,3 +1,21 @@
# ##### 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 logging
import pillarsdk

View File

@@ -1,3 +1,21 @@
# ##### 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 logging
import os.path
import tempfile
@@ -95,9 +113,6 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin,
self._new_async_task(self.async_execute(context))
return {'RUNNING_MODAL'}
def execute(self, context):
return self.invoke(context, None)
async def async_execute(self, context):
"""Entry point of the asynchronous operator."""

View File

@@ -1,4 +1,23 @@
# ##### 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 asyncio
import datetime
import json
import os
import functools
@@ -17,6 +36,9 @@ from pillarsdk.utils import sanitize_filename
from . import cache
SUBCLIENT_ID = 'PILLAR'
TEXTURE_NODE_TYPES = {'texture', 'hdri'}
RFC1123_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT'
_pillar_api = {} # will become a mapping from bool (cached/non-cached) to pillarsdk.Api objects.
log = logging.getLogger(__name__)
@@ -157,6 +179,12 @@ def pillar_api(pillar_endpoint: str = None, caching=True) -> pillarsdk.Api:
token=subclient['token'])
_noncaching_api.requests_session = uncached_session
# Send the addon version as HTTP header.
from blender_cloud import bl_info
addon_version = '.'.join(str(v) for v in bl_info['version'])
_caching_api.global_headers['Blender-Cloud-Addon'] = addon_version
_noncaching_api.global_headers['Blender-Cloud-Addon'] = addon_version
_pillar_api = {
True: _caching_api,
False: _noncaching_api,
@@ -288,7 +316,8 @@ async def get_nodes(project_uuid: str = None, parent_node_uuid: str = None,
if isinstance(node_type, str):
where['node_type'] = node_type
else:
where['node_type'] = {'$in': node_type}
# Convert set & tuple to list
where['node_type'] = {'$in': list(node_type)}
params = {'projection': {'name': 1, 'parent': 1, 'node_type': 1, 'properties.order': 1,
'properties.status': 1, 'properties.files': 1,
@@ -472,7 +501,7 @@ async def fetch_texture_thumbs(parent_node_uuid: str, desired_size: str,
# Download all texture nodes in parallel.
log.debug('Getting child nodes of node %r', parent_node_uuid)
texture_nodes = await get_nodes(parent_node_uuid=parent_node_uuid,
node_type='texture')
node_type=TEXTURE_NODE_TYPES)
if is_cancelled(future):
log.warning('fetch_texture_thumbs: Texture downloading cancelled')
@@ -498,7 +527,7 @@ async def download_texture_thumbnail(texture_node, desired_size: str,
thumbnail_loaded: callable,
future: asyncio.Future = None):
# Skip non-texture nodes, as we can't thumbnail them anyway.
if texture_node['node_type'] != 'texture':
if texture_node['node_type'] not in TEXTURE_NODE_TYPES:
return
if is_cancelled(future):
@@ -526,7 +555,8 @@ async def download_texture_thumbnail(texture_node, desired_size: str,
# Load the File that belongs to this texture node's picture.
loop.call_soon_threadsafe(thumbnail_loading, texture_node, texture_node)
file_desc = await pillar_call(pillarsdk.File.find, pic_uuid, params={
'projection': {'filename': 1, 'variations': 1, 'width': 1, 'height': 1},
'projection': {'filename': 1, 'variations': 1, 'width': 1, 'height': 1,
'length': 1},
})
if file_desc is None:
@@ -555,15 +585,80 @@ async def download_texture_thumbnail(texture_node, desired_size: str,
loop.call_soon_threadsafe(thumbnail_loaded, texture_node, file_desc, thumb_path)
async def fetch_node_files(node: pillarsdk.Node,
*,
file_doc_loading: callable,
file_doc_loaded: callable,
future: asyncio.Future = None):
"""Fetches all files of a texture/hdri node.
@param node: Node document to fetch all file docs for.
@param file_doc_loading: callback function that takes (file_id, ) parameters,
which is called before a file document will be downloaded. This allows you to
show a "downloading" indicator.
@param file_doc_loaded: callback function that takes (file_id, pillarsdk.File object)
parameters, which is called for every thumbnail after it's been downloaded.
@param future: Future that's inspected; if it is not None and cancelled, texture downloading
is aborted.
"""
# Download all thumbnails in parallel.
if is_cancelled(future):
log.warning('fetch_texture_thumbs: Texture downloading cancelled')
return
coros = (download_file_doc(file_ref.file,
file_doc_loading=file_doc_loading,
file_doc_loaded=file_doc_loaded,
future=future)
for file_ref in node.properties.files)
# raises any exception from failed handle_texture_node() calls.
await asyncio.gather(*coros)
log.info('fetch_node_files: Done downloading %i files', len(node.properties.files))
async def download_file_doc(file_id,
*,
file_doc_loading: callable,
file_doc_loaded: callable,
future: asyncio.Future = None):
if is_cancelled(future):
log.debug('fetch_texture_thumbs cancelled before finding File for file_id %s', file_id)
return
loop = asyncio.get_event_loop()
# Load the File that belongs to this texture node's picture.
loop.call_soon_threadsafe(file_doc_loading, file_id)
file_desc = await pillar_call(pillarsdk.File.find, file_id, params={
'projection': {'filename': 1, 'variations': 1, 'width': 1, 'height': 1,
'length': 1},
})
if file_desc is None:
log.warning('Unable to find File for file_id %s', file_id)
loop.call_soon_threadsafe(file_doc_loaded, file_id, file_desc)
async def download_file_by_uuid(file_uuid,
target_directory: str,
metadata_directory: str,
*,
filename: str = None,
map_type: str = None,
file_loading: callable = None,
file_loaded: callable = None,
file_loaded_sync: callable = None,
future: asyncio.Future):
"""Downloads a file from Pillar by its UUID.
:param filename: overrules the filename in file_doc['filename'] if given.
The extension from file_doc['filename'] is still used, though.
"""
if is_cancelled(future):
log.debug('download_file_by_uuid(%r) cancelled.', file_uuid)
return
@@ -572,15 +667,18 @@ async def download_file_by_uuid(file_uuid,
# Find the File document.
file_desc = await pillar_call(pillarsdk.File.find, file_uuid, params={
'projection': {'link': 1, 'filename': 1},
'projection': {'link': 1, 'filename': 1, 'length': 1},
})
# Save the file document to disk
metadata_file = os.path.join(metadata_directory, 'files', '%s.json' % file_uuid)
save_as_json(file_desc, metadata_file)
# Let the caller override the filename root.
root, ext = os.path.splitext(file_desc['filename'])
if map_type is None or root.endswith(map_type):
if filename:
root, _ = os.path.splitext(filename)
if not map_type or root.endswith(map_type):
target_filename = '%s%s' % (root, ext)
else:
target_filename = '%s-%s%s' % (root, map_type, ext)
@@ -589,7 +687,7 @@ async def download_file_by_uuid(file_uuid,
file_url = file_desc['link']
# log.debug('Texture %r:\n%s', file_uuid, pprint.pformat(file_desc.to_dict()))
if file_loading is not None:
loop.call_soon_threadsafe(file_loading, file_path, file_desc)
loop.call_soon_threadsafe(file_loading, file_path, file_desc, map_type)
# Cached headers are stored in the project space
header_store = os.path.join(metadata_directory, 'files',
@@ -598,9 +696,9 @@ async def download_file_by_uuid(file_uuid,
await download_to_file(file_url, file_path, header_store=header_store, future=future)
if file_loaded is not None:
loop.call_soon_threadsafe(file_loaded, file_path, file_desc)
loop.call_soon_threadsafe(file_loaded, file_path, file_desc, map_type)
if file_loaded_sync is not None:
await file_loaded_sync(file_path, file_desc)
await file_loaded_sync(file_path, file_desc, map_type)
async def download_texture(texture_node,
@@ -610,18 +708,25 @@ async def download_texture(texture_node,
texture_loading: callable,
texture_loaded: callable,
future: asyncio.Future):
if texture_node['node_type'] != 'texture':
raise TypeError("Node type should be 'texture', not %r" % texture_node['node_type'])
node_type_name = texture_node['node_type']
if node_type_name not in TEXTURE_NODE_TYPES:
raise TypeError("Node type should be in %r, not %r" %
(TEXTURE_NODE_TYPES, node_type_name))
filename = '%s.taken_from_file' % sanitize_filename(texture_node['name'])
# Download every file. Eve doesn't support embedding from a list-of-dicts.
downloaders = (download_file_by_uuid(file_info['file'],
target_directory,
metadata_directory,
map_type=file_info['map_type'],
file_loading=texture_loading,
file_loaded=texture_loaded,
future=future)
for file_info in texture_node['properties']['files'])
downloaders = []
for file_info in texture_node['properties']['files']:
dlr = download_file_by_uuid(file_info['file'],
target_directory,
metadata_directory,
filename=filename,
map_type=file_info.map_type or file_info.resolution,
file_loading=texture_loading,
file_loaded=texture_loaded,
future=future)
downloaders.append(dlr)
return await asyncio.gather(*downloaders, return_exceptions=True)
@@ -763,6 +868,25 @@ async def attach_file_to_group(file_path: pathlib.Path,
group_node_id,
'file',
str(file_path),
extra_where=user_id and {'user': user_id})
extra_where=user_id and {'user': user_id},
caching=False)
return node
def node_to_id(node: pillarsdk.Node) -> dict:
"""Converts a Node to a dict we can store in an ID property.
ID properties only support a handful of Python classes, so we have
to convert datetime.datetime to a string and remove None values.
"""
def to_rna(value):
if isinstance(value, dict):
return {k: to_rna(v) for k, v in value.items()}
if isinstance(value, datetime.datetime):
return value.strftime(RFC1123_DATE_FORMAT)
return value
as_dict = to_rna(node.to_dict())
return pillarsdk.utils.remove_none_attributes(as_dict)

View File

@@ -1,3 +1,21 @@
# ##### 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 #####
"""Synchronises settings & startup file with the Cloud.
Caching is disabled on many PillarSDK calls, as synchronisation can happen
rapidly between multiple machines. This means that information can be outdated

View File

@@ -1,23 +1,21 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# Copyright (C) 2014 Blender Aid
# http://www.blendearaid.com
# blenderaid@gmail.com
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is 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, see <http://www.gnu.org/licenses/>.
# 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 asyncio
import logging
import threading
@@ -28,7 +26,7 @@ import bgl
import blf
import pillarsdk
from . import async_loop, pillar, cache
from . import async_loop, pillar, cache, blender, utils
REQUIRED_ROLES_FOR_TEXTURE_BROWSER = {'subscriber', 'demo'}
MOUSE_SCROLL_PIXELS_PER_TICK = 50
@@ -43,27 +41,32 @@ ITEM_PADDING_X = 5
library_path = '/tmp'
library_icons_path = os.path.join(os.path.dirname(__file__), "icons")
log = logging.getLogger(__name__)
class SpecialFolderNode(pillarsdk.Node):
pass
NODE_TYPE = 'SPECIAL'
class UpNode(SpecialFolderNode):
NODE_TYPE = 'UP'
def __init__(self):
super().__init__()
self['_id'] = 'UP'
self['node_type'] = 'UP'
self['node_type'] = self.NODE_TYPE
class ProjectNode(SpecialFolderNode):
NODE_TYPE = 'PROJECT'
def __init__(self, project):
super().__init__()
assert isinstance(project, pillarsdk.Project), 'wrong type for project: %r' % type(project)
self.merge(project.to_dict())
self['node_type'] = 'PROJECT'
self['node_type'] = self.NODE_TYPE
class MenuItem:
@@ -81,7 +84,8 @@ class MenuItem:
'SPINNER': os.path.join(library_icons_path, 'spinner.png'),
}
SUPPORTED_NODE_TYPES = {'UP', 'PROJECT', 'group_texture', 'texture'}
FOLDER_NODE_TYPES = {'group_texture', 'group_hdri', UpNode.NODE_TYPE, ProjectNode.NODE_TYPE}
SUPPORTED_NODE_TYPES = {'texture', 'hdri'}.union(FOLDER_NODE_TYPES)
def __init__(self, node, file_desc, thumb_path: str, label_text):
self.log = logging.getLogger('%s.MenuItem' % __name__)
@@ -97,8 +101,8 @@ class MenuItem:
self.label_text = label_text
self._thumb_path = ''
self.icon = None
self._is_folder = (node['node_type'] == 'group_texture' or
isinstance(node, SpecialFolderNode))
self._is_folder = node['node_type'] in self.FOLDER_NODE_TYPES
self._is_spinning = False
# Determine sorting order.
# by default, sort all the way at the end and folders first.
@@ -124,6 +128,8 @@ class MenuItem:
@thumb_path.setter
def thumb_path(self, new_thumb_path: str):
self._is_spinning = new_thumb_path == 'SPINNER'
self._thumb_path = self.DEFAULT_ICONS.get(new_thumb_path, new_thumb_path)
if self._thumb_path:
self.icon = bpy.data.images.load(filepath=self._thumb_path)
@@ -134,7 +140,13 @@ class MenuItem:
def node_uuid(self) -> str:
return self.node['_id']
def update(self, node, file_desc, thumb_path: str, label_text):
def represents(self, node) -> bool:
"""Returns True iff this MenuItem represents the given node."""
node_uuid = node['_id']
return self.node_uuid == node_uuid
def update(self, node, file_desc, thumb_path: str, label_text=None):
# We can get updated information about our Node, but a MenuItem should
# always represent one node, and it shouldn't be shared between nodes.
if self.node_uuid != node['_id']:
@@ -143,12 +155,18 @@ class MenuItem:
self.node = node
self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node.
self.thumb_path = thumb_path
self.label_text = label_text
if label_text is not None:
self.label_text = label_text
@property
def is_folder(self) -> bool:
return self._is_folder
@property
def is_spinning(self) -> bool:
return self._is_spinning
def update_placement(self, x, y, width, height):
"""Use OpenGL to draw this one menu item."""
@@ -220,11 +238,14 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
# This contains a stack of Node objects that lead up to the currently browsed node.
path_stack = []
# This contains a stack of MenuItem objects that lead up to the currently browsed node.
menu_item_stack = []
timer = None
log = logging.getLogger('%s.BlenderCloudBrowser' % __name__)
_menu_item_lock = threading.Lock()
current_display_content = []
current_display_content = [] # list of MenuItems currently displayed
loaded_images = set()
thumbnails_cache = ''
maximized_area = False
@@ -301,7 +322,10 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
selected = self.get_clicked()
if selected:
context.window.cursor_set('HAND')
if selected.is_spinning:
context.window.cursor_set('WAIT')
else:
context.window.cursor_set('HAND')
else:
context.window.cursor_set('DEFAULT')
@@ -322,15 +346,13 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
# No item clicked, ignore it.
return {'RUNNING_MODAL'}
if selected.is_spinning:
# This can happen when the thumbnail information isn't loaded yet.
return {'RUNNING_MODAL'}
if selected.is_folder:
self.descend_node(selected.node)
self.descend_node(selected)
else:
if selected.file_desc is None:
# This can happen when the thumbnail information isn't loaded yet.
# Just ignore the click for now.
# TODO: think of a way to handle this properly.
self.log.debug('Selected item %r has no file_desc', selected)
return {'RUNNING_MODAL'}
self.handle_item_selection(context, selected)
if event.type in {'RIGHTMOUSE', 'ESC'}:
@@ -361,12 +383,13 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self._state = 'PLEASE_SUBSCRIBE'
bpy.context.window.cursor_set('HAND')
def descend_node(self, node):
"""Descends the node hierarchy by visiting this node.
def descend_node(self, menu_item: MenuItem):
"""Descends the node hierarchy by visiting this menu item's node.
Also keeps track of the current node, so that we know where the "up" button should go.
"""
node = menu_item.node
assert isinstance(node, pillarsdk.Node), 'Wrong type %s' % node
if isinstance(node, UpNode):
@@ -375,6 +398,8 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.current_path = self.current_path.parent
if self.path_stack:
self.path_stack.pop()
if self.menu_item_stack:
self.menu_item_stack.pop()
if not self.path_stack:
self.project_name = ''
else:
@@ -385,6 +410,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.current_path /= node['_id']
self.log.debug('Going down to %r', self.current_path)
self.path_stack.append(node)
self.menu_item_stack.append(menu_item)
self.browse_assets()
@@ -439,7 +465,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
# Just make this thread-safe to be on the safe side.
with self._menu_item_lock:
for menu_item in self.current_display_content:
if menu_item.node_uuid == node_uuid:
if menu_item.represents(node):
menu_item.update(node, *args)
self.loaded_images.add(menu_item.icon.filepath_raw)
break
@@ -466,12 +492,6 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.clear_images()
self._scroll_reset()
def thumbnail_loading(node, texture_node):
self.add_menu_item(node, None, 'SPINNER', texture_node['name'])
def thumbnail_loaded(node, file_desc, thumb_path):
self.update_menu_item(node, file_desc, thumb_path, file_desc['filename'])
project_uuid = self.current_path.project_uuid
node_uuid = self.current_path.node_uuid
@@ -479,13 +499,13 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
# Query for sub-nodes of this node.
self.log.debug('Getting subnodes for parent node %r', node_uuid)
children = await pillar.get_nodes(parent_node_uuid=node_uuid,
node_type='group_texture')
node_type={'group_texture', 'group_hdri'})
elif project_uuid:
# Query for top-level nodes.
self.log.debug('Getting subnodes for project node %r', project_uuid)
children = await pillar.get_nodes(project_uuid=project_uuid,
parent_node_uuid='',
node_type='group_texture')
node_type={'group_texture', 'group_hdri'})
else:
# Query for projects
self.log.debug('No node UUID and no project UUID, listing available projects')
@@ -515,6 +535,13 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
os.makedirs(directory, exist_ok=True)
self.log.debug('Fetching texture thumbnails for node %r', node_uuid)
def thumbnail_loading(node, texture_node):
self.add_menu_item(node, None, 'SPINNER', texture_node['name'])
def thumbnail_loaded(node, file_desc, thumb_path):
self.update_menu_item(node, file_desc, thumb_path)
await pillar.fetch_texture_thumbs(node_uuid, 's', directory,
thumbnail_loading=thumbnail_loading,
thumbnail_loaded=thumbnail_loaded,
@@ -595,11 +622,6 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
item.draw(highlighted=item.hits(self.mouse_x, self.mouse_y))
bottom_y = min(y, bottom_y)
bgl.glColor4f(0.24, 0.68, 0.91, 1)
bgl.glRectf(0,
bottom_y - ITEM_MARGIN_Y,
window_region.width,
bottom_y+1 - ITEM_MARGIN_Y)
self.scroll_offset_space_left = window_region.height - bottom_y
self.scroll_offset_max = (self.scroll_offset -
self.scroll_offset_space_left +
@@ -725,23 +747,53 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.log.debug('Metadata will be stored at %s', meta_path)
file_paths = []
select_dblock = None
node = item.node
def texture_downloading(file_path, file_desc, *args):
def texture_downloading(file_path, *_):
self.log.info('Texture downloading to %s', file_path)
def texture_downloaded(file_path, file_desc, *args):
def texture_downloaded(file_path, file_desc, map_type):
nonlocal select_dblock
self.log.info('Texture downloaded to %r.', file_path)
if context.scene.local_texture_dir.startswith('//'):
file_path = bpy.path.relpath(file_path)
image_dblock = bpy.data.images.load(filepath=file_path)
image_dblock['bcloud_file_uuid'] = file_desc['_id']
image_dblock['bcloud_texture_node_uuid'] = item.node_uuid
image_dblock['bcloud_node_uuid'] = node['_id']
image_dblock['bcloud_node_type'] = node['node_type']
image_dblock['bcloud_node'] = pillar.node_to_id(node)
if node['node_type'] == 'hdri':
# All HDRi variations should use the same image datablock, hence once name.
image_dblock.name = node['name']
else:
# All texture variations are loaded at once, and thus need the map type in the name.
image_dblock.name = '%s-%s' % (node['name'], map_type)
# Select the image in the image editor (if the context is right).
# Just set the first image we download,
if context.area.type == 'IMAGE_EDITOR':
if select_dblock is None or file_desc.map_type == 'color':
select_dblock = image_dblock
context.space_data.image = select_dblock
file_paths.append(file_path)
def texture_download_completed(_):
self.log.info('Texture download complete, inspect:\n%s', '\n'.join(file_paths))
self._state = 'QUIT'
# For HDRi nodes: only download the first file.
download_node = pillarsdk.Node.new(node)
if node['node_type'] == 'hdri':
download_node.properties.files = [download_node.properties.files[0]]
signalling_future = asyncio.Future()
self._new_async_task(pillar.download_texture(item.node, local_path,
self._new_async_task(pillar.download_texture(download_node, local_path,
metadata_directory=meta_path,
texture_loading=texture_downloading,
texture_loaded=texture_downloaded,
@@ -782,19 +834,180 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.scroll_offset_target = self.scroll_offset = 0
class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin,
async_loop.AsyncModalOperatorMixin,
bpy.types.Operator):
bl_idname = 'pillar.switch_hdri'
bl_label = 'Switch with another variation'
bl_description = 'Downloads the selected variation of an HDRi, ' \
'replacing the current image'
log = logging.getLogger('bpy.ops.%s' % bl_idname)
image_name = bpy.props.StringProperty(name='image_name',
description='Name of the image block to replace')
file_uuid = bpy.props.StringProperty(name='file_uuid',
description='File ID to download')
def invoke(self, context, event):
async_loop.AsyncModalOperatorMixin.invoke(self, context, event)
self.log.info('Starting')
self._new_async_task(self.async_execute(context))
return {'RUNNING_MODAL'}
async def async_execute(self, context):
"""Entry point of the asynchronous operator."""
self.report({'INFO'}, 'Communicating with Blender Cloud')
try:
try:
user_id = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER)
except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.')
self.report({'ERROR'}, 'Please subscribe to the Blender Cloud.')
self._state = 'QUIT'
return
except pillar.CredentialsNotSyncedError:
self.log.exception('Error checking/refreshing credentials.')
self.report({'ERROR'}, 'Please log in on Blender ID first.')
self._state = 'QUIT'
return
if user_id is None:
raise pillar.UserNotLoggedInError()
await self.download_and_replace(context)
except Exception as ex:
self.log.exception('Unexpected exception caught.')
self.report({'ERROR'}, 'Unexpected error %s: %s' % (type(ex), ex))
self._state = 'QUIT'
async def download_and_replace(self, context):
from .pillar import sanitize_filename
self._state = 'DOWNLOADING_TEXTURE'
current_image = bpy.data.images[self.image_name]
node = current_image['bcloud_node']
filename = '%s.taken_from_file' % sanitize_filename(node['name'])
local_path = os.path.dirname(bpy.path.abspath(current_image.filepath))
top_texture_directory = bpy.path.abspath(context.scene.local_texture_dir)
meta_path = os.path.join(top_texture_directory, '.blender_cloud')
file_uuid = self.file_uuid
resolution = next(file_ref['resolution'] for file_ref in node['properties']['files']
if file_ref['file'] == file_uuid)
self.log.info('Downloading file %r-%s to %s', file_uuid, resolution, local_path)
self.log.debug('Metadata will be stored at %s', meta_path)
def file_loading(file_path, file_desc, map_type):
self.log.info('Texture downloading to %s (%s)',
file_path, utils.sizeof_fmt(file_desc['length']))
async def file_loaded(file_path, file_desc, map_type):
if context.scene.local_texture_dir.startswith('//'):
file_path = bpy.path.relpath(file_path)
self.log.info('Texture downloaded to %s', file_path)
current_image['bcloud_file_uuid'] = file_uuid
current_image.filepath = file_path # This automatically reloads the image from disk.
await pillar.download_file_by_uuid(file_uuid,
local_path,
meta_path,
filename=filename,
map_type=resolution,
file_loading=file_loading,
file_loaded_sync=file_loaded,
future=self.signalling_future)
self.report({'INFO'}, 'Image download complete')
# store keymaps here to access after registration
addon_keymaps = []
def menu_draw(self, context):
layout = self.layout
layout.separator()
layout.operator(BlenderCloudBrowser.bl_idname, icon='MOD_SCREW')
def image_editor_menu(self, context):
self.layout.operator(BlenderCloudBrowser.bl_idname,
text='Get image from Blender Cloud',
icon_value=blender.icon('CLOUD'))
def hdri_download_panel__image_editor(self, context):
_hdri_download_panel(self, context.edit_image)
def hdri_download_panel__node_editor(self, context):
if context.active_node.type not in {'TEX_ENVIRONMENT', 'TEX_IMAGE'}:
return
_hdri_download_panel(self, context.active_node.image)
def _hdri_download_panel(self, current_image):
if not current_image:
return
if 'bcloud_node_type' not in current_image:
return
if current_image['bcloud_node_type'] != 'hdri':
return
try:
current_variation = current_image['bcloud_file_uuid']
except KeyError:
log.warning('Image %r has a bcloud_node_type but no bcloud_file_uuid property.',
current_image.name)
return
row = self.layout.row(align=True).split(0.3)
row.label('HDRi', icon_value=blender.icon('CLOUD'))
row.prop(current_image, 'hdri_variation', text='')
if current_image.hdri_variation != current_variation:
props = row.operator(PILLAR_OT_switch_hdri.bl_idname,
text='Replace',
icon='FILE_REFRESH')
props.image_name = current_image.name
props.file_uuid = current_image.hdri_variation
def hdri_variation_choices(self, context):
if context.area.type == 'IMAGE_EDITOR':
image = context.edit_image
elif context.area.type == 'NODE_EDITOR':
image = context.active_node.image
else:
return []
if 'bcloud_node' not in image:
return []
choices = [(file_doc['file'], file_doc['resolution'], '')
for file_doc in image['bcloud_node']['properties']['files']]
return choices
def register():
bpy.utils.register_class(BlenderCloudBrowser)
# bpy.types.INFO_MT_mesh_add.append(menu_draw)
bpy.utils.register_class(PILLAR_OT_switch_hdri)
bpy.types.IMAGE_MT_image.prepend(image_editor_menu)
bpy.types.IMAGE_PT_image_properties.append(hdri_download_panel__image_editor)
bpy.types.NODE_PT_active_node_properties.append(hdri_download_panel__node_editor)
# HDRi resolution switcher/chooser.
# TODO: when an image is selected, switch this property to its current resolution.
bpy.types.Image.hdri_variation = bpy.props.EnumProperty(
name='HDRi variations',
items=hdri_variation_choices,
description='Select a variation with which to replace this image'
)
# handle the keymap
wm = bpy.context.window_manager
@@ -814,4 +1027,11 @@ def unregister():
km.keymap_items.remove(kmi)
addon_keymaps.clear()
if hasattr(bpy.types.Image, 'hdri_variation'):
del bpy.types.Image.hdri_variation
bpy.types.IMAGE_MT_image.remove(image_editor_menu)
bpy.types.IMAGE_PT_image_properties.remove(hdri_download_panel__image_editor)
bpy.types.NODE_PT_active_node_properties.remove(hdri_download_panel__node_editor)
bpy.utils.unregister_class(BlenderCloudBrowser)
bpy.utils.unregister_class(PILLAR_OT_switch_hdri)

31
blender_cloud/utils.py Normal file
View File

@@ -0,0 +1,31 @@
# ##### 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 #####
def sizeof_fmt(num: int, suffix='B') -> str:
"""Returns a human-readable size.
Source: http://stackoverflow.com/a/1094933/875379
"""
for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']:
if abs(num) < 1024:
return '%.1f %s%s' % (num, unit, suffix)
num /= 1024
return '%.1f Yi%s' % (num, suffix)

View File

@@ -1,3 +1,21 @@
# ##### 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 #####
"""External dependencies loader."""
import glob

View File

@@ -1,7 +1,7 @@
# Primary requirements:
-e git+https://github.com/sybrenstuvel/cachecontrol.git@sybren-filecache-delete-crash-fix#egg=CacheControl
lockfile==0.12.2
pillarsdk==1.4.0
pillarsdk==1.5.0
wheel==0.29.0
# Secondary requirements:

View File

@@ -1,4 +1,21 @@
#!/usr/bin/env python3
# ##### 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 glob
import sys
@@ -179,7 +196,7 @@ setup(
'wheels': BuildWheels},
name='blender_cloud',
description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.',
version='1.3.3',
version='1.4.0',
author='Sybren A. Stüvel',
author_email='sybren@stuvel.eu',
packages=find_packages('.'),