Compare commits
49 Commits
asset-engi
...
version-1.
Author | SHA1 | Date | |
---|---|---|---|
5f5f0d8db9 | |||
30f71ac9fc | |||
bdef942b0b | |||
2a0ef39b12 | |||
c57a3bc902 | |||
b94998d12e | |||
1cd42e246e | |||
079689a532 | |||
597ba6de1c | |||
7b59391872 | |||
8201ba7691 | |||
8f2b0f8faa | |||
33b52cc8a9 | |||
be46b9cf81 | |||
ba4c951d32 | |||
5c7343f8c9 | |||
64d36818fe | |||
07f28d3072 | |||
48ca91a364 | |||
7ee052f71b | |||
2bb859efd9 | |||
ac3943fe6c | |||
5eaee872bf | |||
6ce4399407 | |||
bfa375fed0 | |||
6d7428c16e | |||
ef7c82666f | |||
92e27914d1 | |||
1d662a0314 | |||
f3699f651a | |||
602260329e | |||
58bae5b3a0 | |||
0ccd5cbf97 | |||
61b8667f3b | |||
ad7e9acb5d | |||
329d830f63 | |||
6be1e4ced9 | |||
090a9bc5c6 | |||
d77022ee1f | |||
b36a5178ba | |||
6450db9c9e | |||
b70ab9678b | |||
5ffeddebd1 | |||
a6256bd47b | |||
23540f931f | |||
29230f09e7 | |||
2c4c102302 | |||
5396fd765d | |||
1bb32033b6 |
52
README.md
52
README.md
@@ -19,6 +19,26 @@ This addon is a *proof of concept* demonstrating the following features:
|
|||||||
|
|
||||||
{F299745}
|
{F299745}
|
||||||
|
|
||||||
|
Installing the addon
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
* If you don't have one already, sign up for an account at
|
||||||
|
the [Blender ID site](https://www.blender.org/id/).
|
||||||
|
* Install and log in with the
|
||||||
|
[Blender ID addon](https://developer.blender.org/diffusion/BIA/).
|
||||||
|
* Install the Blender Cloud addon in Blender (User Preferences →
|
||||||
|
Addons → Install from file...) by pointing it to
|
||||||
|
`blender_cloud*.addon.zip`.
|
||||||
|
* Enable the addon in User Preferences → Addons → System.
|
||||||
|
|
||||||
|
Running the addon
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
After installing the Blender Cloud addon, press Ctrl+Alt+Shift+A to
|
||||||
|
activate it (yes, this needs work). Downloaded textures are loaded into
|
||||||
|
image datablocks. The download location can be configured in the addon
|
||||||
|
preferences.
|
||||||
|
|
||||||
|
|
||||||
Building an installable ZIP file
|
Building an installable ZIP file
|
||||||
--------------------------------
|
--------------------------------
|
||||||
@@ -40,38 +60,6 @@ can find them, or be bundled as wheel files in `blender_cloud/wheels`.
|
|||||||
The `python setup.py bdist` command gathers the dependencies and bundles
|
The `python setup.py bdist` command gathers the dependencies and bundles
|
||||||
them as wheel files.
|
them as wheel files.
|
||||||
|
|
||||||
Installing the addon
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
* To build the addon, run `python setup.py bdist` as described above.
|
|
||||||
* If you don't have one already, sign up for an account at
|
|
||||||
the [Blender ID site](https://www.blender.org/id/).
|
|
||||||
* As a final step, install and log in with the
|
|
||||||
[Blender ID addon](https://developer.blender.org/diffusion/BIA/).
|
|
||||||
* Install the Blender Cloud addon in Blender (User Preferences →
|
|
||||||
Addons → Install from file...) by pointing it to
|
|
||||||
`dist/blender_cloud*.addon.zip`.
|
|
||||||
* Enable the addon in User Preferences → Addons → System.
|
|
||||||
|
|
||||||
NOTE: The addon requires HTTPS connections, and thus is dependent on
|
|
||||||
[D1845](https://developer.blender.org/D1845). You can do either of
|
|
||||||
these:
|
|
||||||
|
|
||||||
* Build Blender yourself
|
|
||||||
* Get a recent copy from the buildbot
|
|
||||||
* Copy certificate authority certificate PEM file to
|
|
||||||
`blender/2.77/python/lib/python3.5/site-packages/requests/cacert.pem`.
|
|
||||||
You can use the same file from your local requests installation, or
|
|
||||||
use `/etc/ssl/certs/ca-certificates.crt`.
|
|
||||||
|
|
||||||
Running the addon
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
After installing the Blender Cloud addon, press Ctrl+Alt+Shift+A to
|
|
||||||
activate it (yes, this needs work). Downloaded textures are loaded into
|
|
||||||
image datablocks. The download location can be configured in the addon
|
|
||||||
preferences.
|
|
||||||
|
|
||||||
|
|
||||||
Design
|
Design
|
||||||
------
|
------
|
||||||
|
@@ -19,26 +19,19 @@
|
|||||||
# <pep8 compliant>
|
# <pep8 compliant>
|
||||||
|
|
||||||
bl_info = {
|
bl_info = {
|
||||||
"name": "Blender Cloud Texture Browser",
|
'name': 'Blender Cloud Texture Browser',
|
||||||
"author": "Sybren A. Stüvel and Francesco Siddi",
|
'author': 'Sybren A. Stüvel and Francesco Siddi',
|
||||||
"version": (0, 2, 0),
|
'version': (1, 1, 0),
|
||||||
"blender": (2, 77, 0),
|
'blender': (2, 77, 0),
|
||||||
"location": "TO BE DETERMINED",
|
'location': 'Ctrl+Shift+Alt+A anywhere',
|
||||||
"description": "Allows downloading of textures from the Blender Cloud. Requires "
|
'description': 'Allows downloading of textures from the Blender Cloud. Requires '
|
||||||
"the Blender ID addon.",
|
'the Blender ID addon and Blender 2.77a or newer.',
|
||||||
"wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/"
|
'wiki_url': 'http://wiki.blender.org/index.php/Extensions:2.6/Py/'
|
||||||
"Scripts/System/BlenderCloud",
|
'Scripts/System/BlenderCloud',
|
||||||
"category": "System",
|
'category': 'System',
|
||||||
"support": "TESTING"
|
'support': 'TESTING'
|
||||||
}
|
}
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO,
|
|
||||||
format='%(asctime)-15s %(levelname)8s %(name)s %(message)s')
|
|
||||||
logging.getLogger('cachecontrol').setLevel(logging.DEBUG)
|
|
||||||
logging.getLogger(__name__).setLevel(logging.DEBUG)
|
|
||||||
|
|
||||||
# Support reloading
|
# Support reloading
|
||||||
if 'pillar' in locals():
|
if 'pillar' in locals():
|
||||||
import importlib
|
import importlib
|
||||||
|
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import traceback
|
import traceback
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import logging
|
import logging
|
||||||
|
import gc
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
|
|
||||||
@@ -53,6 +54,9 @@ def kick_async_loop(*args) -> bool:
|
|||||||
len(all_tasks))
|
len(all_tasks))
|
||||||
stop_after_this_kick = True
|
stop_after_this_kick = True
|
||||||
|
|
||||||
|
# Clean up circular references between tasks.
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
for task_idx, task in enumerate(all_tasks):
|
for task_idx, task in enumerate(all_tasks):
|
||||||
if not task.done():
|
if not task.done():
|
||||||
continue
|
continue
|
||||||
@@ -68,6 +72,9 @@ def kick_async_loop(*args) -> bool:
|
|||||||
print('{}: resulted in exception'.format(task))
|
print('{}: resulted in exception'.format(task))
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# for ref in gc.get_referrers(task):
|
||||||
|
# log.debug(' - referred by %s', ref)
|
||||||
|
|
||||||
loop.stop()
|
loop.stop()
|
||||||
loop.run_forever()
|
loop.run_forever()
|
||||||
|
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
Separated from __init__.py so that we can import & run from non-Blender environments.
|
Separated from __init__.py so that we can import & run from non-Blender environments.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os.path
|
import logging
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
from bpy.types import AddonPreferences, Operator, WindowManager, Scene
|
from bpy.types import AddonPreferences, Operator, WindowManager, Scene
|
||||||
@@ -11,16 +11,23 @@ from bpy.props import StringProperty
|
|||||||
|
|
||||||
from . import pillar, gui
|
from . import pillar, gui
|
||||||
|
|
||||||
|
PILLAR_SERVER_URL = 'https://cloudapi.blender.org/'
|
||||||
|
# PILLAR_SERVER_URL = 'http://localhost:5000/'
|
||||||
|
|
||||||
ADDON_NAME = 'blender_cloud'
|
ADDON_NAME = 'blender_cloud'
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BlenderCloudPreferences(AddonPreferences):
|
class BlenderCloudPreferences(AddonPreferences):
|
||||||
bl_idname = ADDON_NAME
|
bl_idname = ADDON_NAME
|
||||||
|
|
||||||
|
# The following two properties are read-only to limit the scope of the
|
||||||
|
# addon and allow for proper testing within this scope.
|
||||||
pillar_server = bpy.props.StringProperty(
|
pillar_server = bpy.props.StringProperty(
|
||||||
name='Blender Cloud Server',
|
name='Blender Cloud Server',
|
||||||
description='URL of the Blender Cloud backend server',
|
description='URL of the Blender Cloud backend server',
|
||||||
default='https://pillar.blender.org:5000/'
|
default=PILLAR_SERVER_URL,
|
||||||
|
get=lambda self: PILLAR_SERVER_URL
|
||||||
)
|
)
|
||||||
|
|
||||||
local_texture_dir = StringProperty(
|
local_texture_dir = StringProperty(
|
||||||
@@ -29,34 +36,45 @@ class BlenderCloudPreferences(AddonPreferences):
|
|||||||
default='//textures')
|
default='//textures')
|
||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
|
import textwrap
|
||||||
|
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
|
|
||||||
# Carefully try and import the Blender ID addon
|
# Carefully try and import the Blender ID addon
|
||||||
try:
|
try:
|
||||||
import blender_id.profiles as blender_id_profiles
|
import blender_id
|
||||||
except ImportError:
|
except ImportError:
|
||||||
blender_id_profiles = None
|
blender_id = None
|
||||||
blender_id_profile = None
|
blender_id_profile = None
|
||||||
else:
|
else:
|
||||||
blender_id_profile = blender_id_profiles.get_active_profile()
|
blender_id_profile = blender_id.get_active_profile()
|
||||||
|
|
||||||
if blender_id_profiles is None:
|
if blender_id is None:
|
||||||
blender_id_icon = 'ERROR'
|
icon = 'ERROR'
|
||||||
blender_id_text = "This add-on requires Blender ID"
|
text = 'This add-on requires Blender ID'
|
||||||
blender_id_help = "Make sure that the Blender ID add-on is installed and activated"
|
help_text = 'Make sure that the Blender ID add-on is installed and activated'
|
||||||
elif not blender_id_profile:
|
elif not blender_id_profile:
|
||||||
blender_id_icon = 'ERROR'
|
icon = 'ERROR'
|
||||||
blender_id_text = "You are logged out."
|
text = 'You are logged out.'
|
||||||
blender_id_help = "To login, go to the Blender ID add-on preferences."
|
help_text = 'To login, go to the Blender ID add-on preferences.'
|
||||||
|
elif pillar.SUBCLIENT_ID not in blender_id_profile.subclients:
|
||||||
|
icon = 'QUESTION'
|
||||||
|
text = 'No Blender Cloud credentials.'
|
||||||
|
help_text = ('You are logged in on Blender ID, but your credentials have not '
|
||||||
|
'been synchronized with Blender Cloud yet. Press the Update '
|
||||||
|
'Credentials button.')
|
||||||
else:
|
else:
|
||||||
blender_id_icon = 'WORLD_DATA'
|
icon = 'WORLD_DATA'
|
||||||
blender_id_text = "You are logged in as %s." % blender_id_profile['username']
|
text = 'You are logged in as %s.' % blender_id_profile.username
|
||||||
blender_id_help = "To logout or change profile, " \
|
help_text = ('To logout or change profile, '
|
||||||
"go to the Blender ID add-on preferences."
|
'go to the Blender ID add-on preferences.')
|
||||||
|
|
||||||
sub = layout.column()
|
sub = layout.column(align=True)
|
||||||
sub.label(text=blender_id_text, icon=blender_id_icon)
|
sub.label(text=text, icon=icon)
|
||||||
sub.label(text="* " + blender_id_help)
|
|
||||||
|
help_lines = textwrap.wrap(help_text, 80)
|
||||||
|
for line in help_lines:
|
||||||
|
sub.label(text=line)
|
||||||
|
|
||||||
sub = layout.column()
|
sub = layout.column()
|
||||||
sub.label(text='Local directory for downloaded textures')
|
sub.label(text='Local directory for downloaded textures')
|
||||||
@@ -65,15 +83,19 @@ class BlenderCloudPreferences(AddonPreferences):
|
|||||||
|
|
||||||
# options for Pillar
|
# options for Pillar
|
||||||
sub = layout.column()
|
sub = layout.column()
|
||||||
sub.enabled = blender_id_icon != 'ERROR'
|
sub.enabled = icon != 'ERROR'
|
||||||
sub.prop(self, "pillar_server")
|
|
||||||
|
# TODO: let users easily pick a project. For now, we just use the
|
||||||
|
# hard-coded server URL and UUID of the textures project.
|
||||||
|
# sub.prop(self, "pillar_server")
|
||||||
|
# sub.prop(self, "project_uuid")
|
||||||
sub.operator("pillar.credentials_update")
|
sub.operator("pillar.credentials_update")
|
||||||
|
|
||||||
|
|
||||||
class PillarCredentialsUpdate(Operator):
|
class PillarCredentialsUpdate(Operator):
|
||||||
"""Updates the Pillar URL and tests the new URL."""
|
"""Updates the Pillar URL and tests the new URL."""
|
||||||
bl_idname = "pillar.credentials_update"
|
bl_idname = 'pillar.credentials_update'
|
||||||
bl_label = "Update credentials"
|
bl_label = 'Update credentials'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
@@ -82,26 +104,35 @@ class PillarCredentialsUpdate(Operator):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_logged_in(cls, context):
|
def is_logged_in(cls, context):
|
||||||
active_user_id = getattr(context.window_manager, 'blender_id_active_profile', None)
|
try:
|
||||||
return bool(active_user_id)
|
import blender_id
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return blender_id.is_logged_in()
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
|
import blender_id
|
||||||
|
import asyncio
|
||||||
|
|
||||||
# Only allow activation when the user is actually logged in.
|
# Only allow activation when the user is actually logged in.
|
||||||
if not self.is_logged_in(context):
|
if not self.is_logged_in(context):
|
||||||
self.report({'ERROR'}, "No active profile found")
|
self.report({'ERROR'}, 'No active profile found')
|
||||||
return {'CANCELLED'}
|
return {'CANCELLED'}
|
||||||
|
|
||||||
# Test the new URL
|
|
||||||
endpoint = bpy.context.user_preferences.addons[ADDON_NAME].preferences.pillar_server
|
|
||||||
pillar._pillar_api = None
|
|
||||||
try:
|
try:
|
||||||
pillar.get_project_uuid('textures') # Just any query will do.
|
loop = asyncio.get_event_loop()
|
||||||
except Exception as e:
|
loop.run_until_complete(pillar.refresh_pillar_credentials())
|
||||||
print(e)
|
except blender_id.BlenderIdCommError as ex:
|
||||||
self.report({'ERROR'}, 'Failed connection to %s' % endpoint)
|
log.exception('Error sending subclient-specific token to Blender ID')
|
||||||
return {'FINISHED'}
|
self.report({'ERROR'}, 'Failed to sync Blender ID to Blender Cloud')
|
||||||
|
return {'CANCELLED'}
|
||||||
|
except Exception as ex:
|
||||||
|
log.exception('Error in test call to Pillar')
|
||||||
|
self.report({'ERROR'}, 'Failed test connection to Blender Cloud')
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
self.report({'INFO'}, 'Updated cloud server address to %s' % endpoint)
|
self.report({'INFO'}, 'Blender Cloud credentials & endpoint URL updated.')
|
||||||
return {'FINISHED'}
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
@@ -113,16 +144,12 @@ def register():
|
|||||||
bpy.utils.register_class(BlenderCloudPreferences)
|
bpy.utils.register_class(BlenderCloudPreferences)
|
||||||
bpy.utils.register_class(PillarCredentialsUpdate)
|
bpy.utils.register_class(PillarCredentialsUpdate)
|
||||||
|
|
||||||
WindowManager.blender_cloud_project = StringProperty(
|
|
||||||
name="Blender Cloud project UUID",
|
|
||||||
default='5672beecc0261b2005ed1a33') # TODO: don't hard-code this
|
|
||||||
|
|
||||||
WindowManager.blender_cloud_node = StringProperty(
|
|
||||||
name="Blender Cloud node UUID",
|
|
||||||
default='') # empty == top-level of project
|
|
||||||
|
|
||||||
addon_prefs = preferences()
|
addon_prefs = preferences()
|
||||||
|
|
||||||
|
WindowManager.last_blender_cloud_location = StringProperty(
|
||||||
|
name="Last Blender Cloud browser location",
|
||||||
|
default="/")
|
||||||
|
|
||||||
def default_if_empty(scene, context):
|
def default_if_empty(scene, context):
|
||||||
"""The scene's local_texture_dir, if empty, reverts to the addon prefs."""
|
"""The scene's local_texture_dir, if empty, reverts to the addon prefs."""
|
||||||
|
|
||||||
|
@@ -34,12 +34,16 @@ def cache_directory(*subdirs) -> str:
|
|||||||
|
|
||||||
from . import pillar
|
from . import pillar
|
||||||
|
|
||||||
profile = pillar.blender_id_profile() or {'username': 'anonymous'}
|
profile = pillar.blender_id_profile()
|
||||||
|
if profile:
|
||||||
|
username = profile.username
|
||||||
|
else:
|
||||||
|
username = 'anonymous'
|
||||||
|
|
||||||
# TODO: use bpy.utils.user_resource('CACHE', ...)
|
# TODO: use bpy.utils.user_resource('CACHE', ...)
|
||||||
# once https://developer.blender.org/T47684 is finished.
|
# once https://developer.blender.org/T47684 is finished.
|
||||||
user_cache_dir = appdirs.user_cache_dir(appname='Blender', appauthor=False)
|
user_cache_dir = appdirs.user_cache_dir(appname='Blender', appauthor=False)
|
||||||
cache_dir = os.path.join(user_cache_dir, 'blender_cloud', profile['username'], *subdirs)
|
cache_dir = os.path.join(user_cache_dir, 'blender_cloud', username, *subdirs)
|
||||||
|
|
||||||
os.makedirs(cache_dir, mode=0o700, exist_ok=True)
|
os.makedirs(cache_dir, mode=0o700, exist_ok=True)
|
||||||
|
|
||||||
|
@@ -44,13 +44,27 @@ library_path = '/tmp'
|
|||||||
library_icons_path = os.path.join(os.path.dirname(__file__), "icons")
|
library_icons_path = os.path.join(os.path.dirname(__file__), "icons")
|
||||||
|
|
||||||
|
|
||||||
class UpNode(pillarsdk.Node):
|
class SpecialFolderNode(pillarsdk.Node):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UpNode(SpecialFolderNode):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self['_id'] = 'UP'
|
self['_id'] = 'UP'
|
||||||
self['node_type'] = 'UP'
|
self['node_type'] = 'UP'
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectNode(SpecialFolderNode):
|
||||||
|
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'
|
||||||
|
|
||||||
|
|
||||||
class MenuItem:
|
class MenuItem:
|
||||||
"""GUI menu item for the 3D View GUI."""
|
"""GUI menu item for the 3D View GUI."""
|
||||||
|
|
||||||
@@ -66,19 +80,30 @@ class MenuItem:
|
|||||||
'SPINNER': os.path.join(library_icons_path, 'spinner.png'),
|
'SPINNER': os.path.join(library_icons_path, 'spinner.png'),
|
||||||
}
|
}
|
||||||
|
|
||||||
SUPPORTED_NODE_TYPES = {'UP', 'group_texture', 'texture'}
|
SUPPORTED_NODE_TYPES = {'UP', 'PROJECT', 'group_texture', 'texture'}
|
||||||
|
|
||||||
def __init__(self, node, file_desc, thumb_path: str, label_text):
|
def __init__(self, node, file_desc, thumb_path: str, label_text):
|
||||||
|
self.log = logging.getLogger('%s.MenuItem' % __name__)
|
||||||
if node['node_type'] not in self.SUPPORTED_NODE_TYPES:
|
if node['node_type'] not in self.SUPPORTED_NODE_TYPES:
|
||||||
|
self.log.info('Invalid node type in node: %s', node)
|
||||||
raise TypeError('Node of type %r not supported; supported are %r.' % (
|
raise TypeError('Node of type %r not supported; supported are %r.' % (
|
||||||
node.group_texture, self.SUPPORTED_NODE_TYPES))
|
node['node_type'], self.SUPPORTED_NODE_TYPES))
|
||||||
|
|
||||||
|
assert isinstance(node, pillarsdk.Node), 'wrong type for node: %r' % type(node)
|
||||||
|
assert isinstance(node['_id'], str), 'wrong type for node["_id"]: %r' % type(node['_id'])
|
||||||
self.node = node # pillarsdk.Node, contains 'node_type' key to indicate type
|
self.node = node # pillarsdk.Node, contains 'node_type' key to indicate type
|
||||||
self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node.
|
self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node.
|
||||||
self.label_text = label_text
|
self.label_text = label_text
|
||||||
self._thumb_path = ''
|
self._thumb_path = ''
|
||||||
self.icon = None
|
self.icon = None
|
||||||
self._is_folder = node['node_type'] == 'group_texture' or isinstance(node, UpNode)
|
self._is_folder = (node['node_type'] == 'group_texture' or
|
||||||
|
isinstance(node, SpecialFolderNode))
|
||||||
|
|
||||||
|
# Determine sorting order.
|
||||||
|
# by default, sort all the way at the end and folders first.
|
||||||
|
self._order = 0 if self._is_folder else 10000
|
||||||
|
if node and node.properties and node.properties.order is not None:
|
||||||
|
self._order = node.properties.order
|
||||||
|
|
||||||
self.thumb_path = thumb_path
|
self.thumb_path = thumb_path
|
||||||
|
|
||||||
@@ -88,6 +113,10 @@ class MenuItem:
|
|||||||
self.width = 0
|
self.width = 0
|
||||||
self.height = 0
|
self.height = 0
|
||||||
|
|
||||||
|
def sort_key(self):
|
||||||
|
"""Key for sorting lists of MenuItems."""
|
||||||
|
return self._order, self.label_text
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def thumb_path(self) -> str:
|
def thumb_path(self) -> str:
|
||||||
return self._thumb_path
|
return self._thumb_path
|
||||||
@@ -182,14 +211,12 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
|
|
||||||
_draw_handle = None
|
_draw_handle = None
|
||||||
|
|
||||||
_state = 'BROWSING'
|
_state = 'INITIALIZING'
|
||||||
|
|
||||||
project_uuid = '5672beecc0261b2005ed1a33' # Blender Cloud project UUID
|
current_path = pillar.CloudPath('/')
|
||||||
node = None # The Node object we're currently showing, or None if we're at the project top.
|
project_name = ''
|
||||||
node_uuid = '' # Blender Cloud node UUID we're currently showing, i.e. None-safe self.node['_id']
|
|
||||||
|
|
||||||
# This contains a stack of Node objects that lead up to the currently browsed node.
|
# This contains a stack of Node objects that lead up to the currently browsed node.
|
||||||
# This allows us to display the "up" item.
|
|
||||||
path_stack = []
|
path_stack = []
|
||||||
|
|
||||||
async_task = None # asyncio task for fetching thumbnails
|
async_task = None # asyncio task for fetching thumbnails
|
||||||
@@ -198,7 +225,6 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
log = logging.getLogger('%s.BlenderCloudBrowser' % __name__)
|
log = logging.getLogger('%s.BlenderCloudBrowser' % __name__)
|
||||||
|
|
||||||
_menu_item_lock = threading.Lock()
|
_menu_item_lock = threading.Lock()
|
||||||
current_path = ''
|
|
||||||
current_display_content = []
|
current_display_content = []
|
||||||
loaded_images = set()
|
loaded_images = set()
|
||||||
thumbnails_cache = ''
|
thumbnails_cache = ''
|
||||||
@@ -208,10 +234,16 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
mouse_y = 0
|
mouse_y = 0
|
||||||
|
|
||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
|
# Refuse to start if the file hasn't been saved.
|
||||||
|
if not context.blend_data.is_saved:
|
||||||
|
self.report({'ERROR'}, 'Please save your Blend file before using '
|
||||||
|
'the Blender Cloud addon.')
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
wm = context.window_manager
|
wm = context.window_manager
|
||||||
self.project_uuid = wm.blender_cloud_project
|
|
||||||
self.node_uuid = wm.blender_cloud_node
|
self.current_path = pillar.CloudPath(wm.last_blender_cloud_location)
|
||||||
self.path_stack = []
|
self.path_stack = [] # list of nodes that make up the current path.
|
||||||
|
|
||||||
self.thumbnails_cache = cache.cache_directory('thumbnails')
|
self.thumbnails_cache = cache.cache_directory('thumbnails')
|
||||||
self.mouse_x = event.mouse_x
|
self.mouse_x = event.mouse_x
|
||||||
@@ -229,10 +261,11 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
|
|
||||||
self.current_display_content = []
|
self.current_display_content = []
|
||||||
self.loaded_images = set()
|
self.loaded_images = set()
|
||||||
self.browse_assets()
|
self.check_credentials()
|
||||||
|
|
||||||
|
context.window.cursor_modal_set('DEFAULT')
|
||||||
context.window_manager.modal_handler_add(self)
|
context.window_manager.modal_handler_add(self)
|
||||||
self.timer = context.window_manager.event_timer_add(1 / 30, context.window)
|
self.timer = context.window_manager.event_timer_add(1 / 15, context.window)
|
||||||
|
|
||||||
return {'RUNNING_MODAL'}
|
return {'RUNNING_MODAL'}
|
||||||
|
|
||||||
@@ -262,9 +295,21 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
self.mouse_x = event.mouse_x
|
self.mouse_x = event.mouse_x
|
||||||
self.mouse_y = event.mouse_y
|
self.mouse_y = event.mouse_y
|
||||||
|
|
||||||
if self._state == 'BROWSING' and event.type == 'LEFTMOUSE' and event.value == 'RELEASE':
|
left_mouse_release = event.type == 'LEFTMOUSE' and event.value == 'RELEASE'
|
||||||
|
if self._state == 'PLEASE_SUBSCRIBE' and left_mouse_release:
|
||||||
|
self.open_browser_subscribe()
|
||||||
|
self._finish(context)
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
if self._state == 'BROWSING':
|
||||||
selected = self.get_clicked()
|
selected = self.get_clicked()
|
||||||
|
|
||||||
|
if selected:
|
||||||
|
context.window.cursor_set('HAND')
|
||||||
|
else:
|
||||||
|
context.window.cursor_set('DEFAULT')
|
||||||
|
|
||||||
|
if left_mouse_release:
|
||||||
if selected is None:
|
if selected is None:
|
||||||
# No item clicked, ignore it.
|
# No item clicked, ignore it.
|
||||||
return {'RUNNING_MODAL'}
|
return {'RUNNING_MODAL'}
|
||||||
@@ -276,36 +321,92 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
# This can happen when the thumbnail information isn't loaded yet.
|
# This can happen when the thumbnail information isn't loaded yet.
|
||||||
# Just ignore the click for now.
|
# Just ignore the click for now.
|
||||||
# TODO: think of a way to handle this properly.
|
# TODO: think of a way to handle this properly.
|
||||||
|
self.log.debug('Selected item %r has no file_desc', selected)
|
||||||
return {'RUNNING_MODAL'}
|
return {'RUNNING_MODAL'}
|
||||||
self.handle_item_selection(context, selected)
|
self.handle_item_selection(context, selected)
|
||||||
|
|
||||||
elif event.type in {'RIGHTMOUSE', 'ESC'}:
|
if event.type in {'RIGHTMOUSE', 'ESC'}:
|
||||||
self._finish(context)
|
self._finish(context)
|
||||||
return {'CANCELLED'}
|
return {'CANCELLED'}
|
||||||
|
|
||||||
return {'RUNNING_MODAL'}
|
return {'RUNNING_MODAL'}
|
||||||
|
|
||||||
|
def check_credentials(self):
|
||||||
|
self._state = 'CHECKING_CREDENTIALS'
|
||||||
|
self.log.debug('Checking credentials')
|
||||||
|
self._new_async_task(self._check_credentials())
|
||||||
|
|
||||||
|
async def _check_credentials(self):
|
||||||
|
"""Checks credentials with Pillar, and if ok goes to the BROWSING state."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
await pillar.check_pillar_credentials()
|
||||||
|
except pillar.NotSubscribedToCloudError:
|
||||||
|
self.log.info('User not subscribed to Blender Cloud.')
|
||||||
|
self._show_subscribe_screen()
|
||||||
|
return
|
||||||
|
except pillar.CredentialsNotSyncedError:
|
||||||
|
self.log.info('Credentials not synced, re-syncing automatically.')
|
||||||
|
else:
|
||||||
|
self.log.info('Credentials okay, browsing assets.')
|
||||||
|
await self.async_download_previews()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await pillar.refresh_pillar_credentials()
|
||||||
|
except pillar.NotSubscribedToCloudError:
|
||||||
|
self.log.info('User is not a Blender Cloud subscriber.')
|
||||||
|
self._show_subscribe_screen()
|
||||||
|
return
|
||||||
|
except pillar.UserNotLoggedInError:
|
||||||
|
self.log.error('User not logged in on Blender ID.')
|
||||||
|
else:
|
||||||
|
self.log.info('Credentials refreshed and ok, browsing assets.')
|
||||||
|
await self.async_download_previews()
|
||||||
|
return
|
||||||
|
|
||||||
|
raise pillar.UserNotLoggedInError()
|
||||||
|
# self._new_async_task(self._check_credentials())
|
||||||
|
|
||||||
|
def _show_subscribe_screen(self):
|
||||||
|
"""Shows the "You need to subscribe" screen."""
|
||||||
|
|
||||||
|
self._state = 'PLEASE_SUBSCRIBE'
|
||||||
|
bpy.context.window.cursor_set('HAND')
|
||||||
|
|
||||||
def descend_node(self, node):
|
def descend_node(self, node):
|
||||||
"""Descends the node hierarchy by visiting this node.
|
"""Descends the node hierarchy by visiting this node.
|
||||||
|
|
||||||
Also keeps track of the current node, so that we know where the "up" button should go.
|
Also keeps track of the current node, so that we know where the "up" button should go.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Going up or down?
|
assert isinstance(node, pillarsdk.Node), 'Wrong type %s' % node
|
||||||
if self.path_stack and isinstance(node, UpNode):
|
|
||||||
self.log.debug('Going up, pop the stack; pre-pop stack is %r', self.path_stack)
|
|
||||||
node = self.path_stack.pop()
|
|
||||||
|
|
||||||
|
if isinstance(node, UpNode):
|
||||||
|
# Going up.
|
||||||
|
self.log.debug('Going up to %r', self.current_path)
|
||||||
|
self.current_path = self.current_path.parent
|
||||||
|
if self.path_stack:
|
||||||
|
self.path_stack.pop()
|
||||||
|
if not self.path_stack:
|
||||||
|
self.project_name = ''
|
||||||
else:
|
else:
|
||||||
# Going down, keep track of where we were (project top-level is None)
|
# Going down, keep track of where we were
|
||||||
self.path_stack.append(self.node)
|
if isinstance(node, ProjectNode):
|
||||||
self.log.debug('Going up, push the stack; post-push stack is %r', self.path_stack)
|
self.project_name = node['name']
|
||||||
|
|
||||||
|
self.current_path /= node['_id']
|
||||||
|
self.log.debug('Going down to %r', self.current_path)
|
||||||
|
self.path_stack.append(node)
|
||||||
|
|
||||||
# Set 'current' to the given node
|
|
||||||
self.node_uuid = node['_id'] if node else None
|
|
||||||
self.node = node
|
|
||||||
self.browse_assets()
|
self.browse_assets()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def node(self):
|
||||||
|
if not self.path_stack:
|
||||||
|
return None
|
||||||
|
return self.path_stack[-1]
|
||||||
|
|
||||||
def _stop_async_task(self):
|
def _stop_async_task(self):
|
||||||
self.log.debug('Stopping async task')
|
self.log.debug('Stopping async task')
|
||||||
if self.async_task is None:
|
if self.async_task is None:
|
||||||
@@ -313,6 +414,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Signal that we want to stop.
|
# Signal that we want to stop.
|
||||||
|
self.async_task.cancel()
|
||||||
if not self.signalling_future.done():
|
if not self.signalling_future.done():
|
||||||
self.log.info("Signalling that we want to cancel anything that's running.")
|
self.log.info("Signalling that we want to cancel anything that's running.")
|
||||||
self.signalling_future.cancel()
|
self.signalling_future.cancel()
|
||||||
@@ -342,6 +444,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
|
|
||||||
context.space_data.draw_handler_remove(self._draw_handle, 'WINDOW')
|
context.space_data.draw_handler_remove(self._draw_handle, 'WINDOW')
|
||||||
context.window_manager.event_timer_remove(self.timer)
|
context.window_manager.event_timer_remove(self.timer)
|
||||||
|
context.window.cursor_modal_restore()
|
||||||
|
|
||||||
if self.maximized_area:
|
if self.maximized_area:
|
||||||
bpy.ops.screen.screen_full_area(use_hide_panels=True)
|
bpy.ops.screen.screen_full_area(use_hide_panels=True)
|
||||||
@@ -370,6 +473,8 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
self.current_display_content.append(menu_item)
|
self.current_display_content.append(menu_item)
|
||||||
self.loaded_images.add(menu_item.icon.filepath_raw)
|
self.loaded_images.add(menu_item.icon.filepath_raw)
|
||||||
|
|
||||||
|
self.sort_menu()
|
||||||
|
|
||||||
return menu_item
|
return menu_item
|
||||||
|
|
||||||
def update_menu_item(self, node, *args) -> MenuItem:
|
def update_menu_item(self, node, *args) -> MenuItem:
|
||||||
@@ -385,8 +490,23 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
else:
|
else:
|
||||||
raise ValueError('Unable to find MenuItem(node_uuid=%r)' % node_uuid)
|
raise ValueError('Unable to find MenuItem(node_uuid=%r)' % node_uuid)
|
||||||
|
|
||||||
async def async_download_previews(self, thumbnails_directory):
|
self.sort_menu()
|
||||||
|
|
||||||
|
def sort_menu(self):
|
||||||
|
"""Sorts the self.current_display_content list."""
|
||||||
|
|
||||||
|
if not self.current_display_content:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._menu_item_lock:
|
||||||
|
self.current_display_content.sort(key=MenuItem.sort_key)
|
||||||
|
|
||||||
|
async def async_download_previews(self):
|
||||||
|
self._state = 'BROWSING'
|
||||||
|
|
||||||
|
thumbnails_directory = self.thumbnails_cache
|
||||||
self.log.info('Asynchronously downloading previews to %r', thumbnails_directory)
|
self.log.info('Asynchronously downloading previews to %r', thumbnails_directory)
|
||||||
|
self.log.info('Current BCloud path is %r', self.current_path)
|
||||||
self.clear_images()
|
self.clear_images()
|
||||||
|
|
||||||
def thumbnail_loading(node, texture_node):
|
def thumbnail_loading(node, texture_node):
|
||||||
@@ -395,48 +515,57 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
def thumbnail_loaded(node, file_desc, thumb_path):
|
def thumbnail_loaded(node, file_desc, thumb_path):
|
||||||
self.update_menu_item(node, file_desc, thumb_path, file_desc['filename'])
|
self.update_menu_item(node, file_desc, thumb_path, file_desc['filename'])
|
||||||
|
|
||||||
# Download either by group_texture node UUID or by project UUID (which shows all top-level nodes)
|
project_uuid = self.current_path.project_uuid
|
||||||
if self.node_uuid:
|
node_uuid = self.current_path.node_uuid
|
||||||
self.log.debug('Getting subnodes for parent node %r', self.node_uuid)
|
|
||||||
children = await pillar.get_nodes(parent_node_uuid=self.node_uuid,
|
|
||||||
node_type='group_textures')
|
|
||||||
|
|
||||||
# Make sure we can go up again.
|
|
||||||
if self.path_stack:
|
|
||||||
self.add_menu_item(UpNode(), None, 'FOLDER', '.. up ..')
|
|
||||||
elif self.project_uuid:
|
|
||||||
self.log.debug('Getting subnodes for project node %r', self.project_uuid)
|
|
||||||
children = await pillar.get_nodes(self.project_uuid, '')
|
|
||||||
|
|
||||||
|
if node_uuid:
|
||||||
|
# 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')
|
||||||
|
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')
|
||||||
else:
|
else:
|
||||||
# TODO: add "nothing here" icon and trigger re-draw
|
# Query for projects
|
||||||
self.log.warning("Not node UUID and no project UUID, I can't do anything!")
|
self.log.debug('No node UUID and no project UUID, listing all projects')
|
||||||
|
children = await pillar.get_texture_projects()
|
||||||
|
for proj_dict in children:
|
||||||
|
self.add_menu_item(ProjectNode(proj_dict), None, 'FOLDER', proj_dict['name'])
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Make sure we can go up again.
|
||||||
|
self.add_menu_item(UpNode(), None, 'FOLDER', '.. up ..')
|
||||||
|
|
||||||
# Download all child nodes
|
# Download all child nodes
|
||||||
self.log.debug('Iterating over child nodes of %r', self.node_uuid)
|
self.log.debug('Iterating over child nodes of %r', self.current_path)
|
||||||
for child in children:
|
for child in children:
|
||||||
# print(' - %(_id)s = %(name)s' % child)
|
# print(' - %(_id)s = %(name)s' % child)
|
||||||
|
if child['node_type'] not in MenuItem.SUPPORTED_NODE_TYPES:
|
||||||
|
self.log.debug('Skipping node of type %r', child['node_type'])
|
||||||
|
continue
|
||||||
self.add_menu_item(child, None, 'FOLDER', child['name'])
|
self.add_menu_item(child, None, 'FOLDER', child['name'])
|
||||||
|
|
||||||
# There are only sub-nodes at the project level, no texture nodes,
|
# There are only sub-nodes at the project level, no texture nodes,
|
||||||
# so we won't have to bother looking for textures.
|
# so we won't have to bother looking for textures.
|
||||||
if not self.node_uuid:
|
if not node_uuid:
|
||||||
return
|
return
|
||||||
|
|
||||||
directory = os.path.join(thumbnails_directory, self.project_uuid, self.node_uuid)
|
directory = os.path.join(thumbnails_directory, project_uuid, node_uuid)
|
||||||
os.makedirs(directory, exist_ok=True)
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
|
||||||
self.log.debug('Fetching texture thumbnails for node %r', self.node_uuid)
|
self.log.debug('Fetching texture thumbnails for node %r', node_uuid)
|
||||||
await pillar.fetch_texture_thumbs(self.node_uuid, 's', directory,
|
await pillar.fetch_texture_thumbs(node_uuid, 's', directory,
|
||||||
thumbnail_loading=thumbnail_loading,
|
thumbnail_loading=thumbnail_loading,
|
||||||
thumbnail_loaded=thumbnail_loaded,
|
thumbnail_loaded=thumbnail_loaded,
|
||||||
future=self.signalling_future)
|
future=self.signalling_future)
|
||||||
|
|
||||||
def browse_assets(self):
|
def browse_assets(self):
|
||||||
self._state = 'BROWSING'
|
self.log.debug('Browsing assets at %r', self.current_path)
|
||||||
self.log.debug('Browsing assets at project %r node %r', self.project_uuid, self.node_uuid)
|
self._new_async_task(self.async_download_previews())
|
||||||
self._new_async_task(self.async_download_previews(self.thumbnails_cache))
|
|
||||||
|
|
||||||
def _new_async_task(self, async_task: asyncio.coroutine, future: asyncio.Future = None):
|
def _new_async_task(self, async_task: asyncio.coroutine, future: asyncio.Future = None):
|
||||||
"""Stops the currently running async task, and starts another one."""
|
"""Stops the currently running async task, and starts another one."""
|
||||||
@@ -456,9 +585,11 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
"""Draws the GUI with OpenGL."""
|
"""Draws the GUI with OpenGL."""
|
||||||
|
|
||||||
drawers = {
|
drawers = {
|
||||||
|
'CHECKING_CREDENTIALS': self._draw_checking_credentials,
|
||||||
'BROWSING': self._draw_browser,
|
'BROWSING': self._draw_browser,
|
||||||
'DOWNLOADING_TEXTURE': self._draw_downloading,
|
'DOWNLOADING_TEXTURE': self._draw_downloading,
|
||||||
'EXCEPTION': self._draw_exception,
|
'EXCEPTION': self._draw_exception,
|
||||||
|
'PLEASE_SUBSCRIBE': self._draw_subscribe,
|
||||||
}
|
}
|
||||||
|
|
||||||
if self._state in drawers:
|
if self._state in drawers:
|
||||||
@@ -470,7 +601,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
|
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
|
||||||
blf.size(font_id, 20, 72)
|
blf.size(font_id, 20, 72)
|
||||||
blf.position(font_id, 5, 5, 0)
|
blf.position(font_id, 5, 5, 0)
|
||||||
blf.draw(font_id, self._state)
|
blf.draw(font_id, '%s %s' % (self._state, self.project_name))
|
||||||
bgl.glDisable(bgl.GL_BLEND)
|
bgl.glDisable(bgl.GL_BLEND)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -530,14 +661,24 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
def _draw_downloading(self, context):
|
def _draw_downloading(self, context):
|
||||||
"""OpenGL drawing code for the DOWNLOADING_TEXTURE state."""
|
"""OpenGL drawing code for the DOWNLOADING_TEXTURE state."""
|
||||||
|
|
||||||
content_height, content_width = self._window_size(context)
|
self._draw_text_on_colour(context,
|
||||||
|
'Downloading texture from Blender Cloud',
|
||||||
|
(0.0, 0.0, 0.2, 0.6))
|
||||||
|
|
||||||
|
def _draw_checking_credentials(self, context):
|
||||||
|
"""OpenGL drawing code for the CHECKING_CREDENTIALS state."""
|
||||||
|
|
||||||
|
self._draw_text_on_colour(context,
|
||||||
|
'Checking login credentials',
|
||||||
|
(0.0, 0.0, 0.2, 0.6))
|
||||||
|
|
||||||
|
def _draw_text_on_colour(self, context, text, bgcolour):
|
||||||
|
content_height, content_width = self._window_size(context)
|
||||||
bgl.glEnable(bgl.GL_BLEND)
|
bgl.glEnable(bgl.GL_BLEND)
|
||||||
bgl.glColor4f(0.0, 0.0, 0.2, 0.6)
|
bgl.glColor4f(*bgcolour)
|
||||||
bgl.glRectf(0, 0, content_width, content_height)
|
bgl.glRectf(0, 0, content_width, content_height)
|
||||||
|
|
||||||
font_id = 0
|
font_id = 0
|
||||||
text = "Downloading texture from Blender Cloud"
|
|
||||||
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
|
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
|
||||||
blf.size(font_id, 20, 72)
|
blf.size(font_id, 20, 72)
|
||||||
text_width, text_height = blf.dimensions(font_id, text)
|
text_width, text_height = blf.dimensions(font_id, text)
|
||||||
@@ -566,7 +707,15 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
bgl.glRectf(0, 0, content_width, content_height)
|
bgl.glRectf(0, 0, content_width, content_height)
|
||||||
|
|
||||||
font_id = 0
|
font_id = 0
|
||||||
text = "An error occurred:\n%s" % self.async_task.exception()
|
ex = self.async_task.exception()
|
||||||
|
if isinstance(ex, pillar.UserNotLoggedInError):
|
||||||
|
ex_msg = 'You are not logged in on Blender ID. Please log in at User Preferences, ' \
|
||||||
|
'System, Blender ID.'
|
||||||
|
else:
|
||||||
|
ex_msg = str(ex)
|
||||||
|
if not ex_msg:
|
||||||
|
ex_msg = str(type(ex))
|
||||||
|
text = "An error occurred:\n%s" % ex_msg
|
||||||
lines = textwrap.wrap(text)
|
lines = textwrap.wrap(text)
|
||||||
|
|
||||||
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
|
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
|
||||||
@@ -583,6 +732,11 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
blf.draw(font_id, line)
|
blf.draw(font_id, line)
|
||||||
bgl.glDisable(bgl.GL_BLEND)
|
bgl.glDisable(bgl.GL_BLEND)
|
||||||
|
|
||||||
|
def _draw_subscribe(self, context):
|
||||||
|
self._draw_text_on_colour(context,
|
||||||
|
'Click to subscribe to the Blender Cloud',
|
||||||
|
(0.0, 0.0, 0.2, 0.6))
|
||||||
|
|
||||||
def get_clicked(self) -> MenuItem:
|
def get_clicked(self) -> MenuItem:
|
||||||
|
|
||||||
for item in self.current_display_content:
|
for item in self.current_display_content:
|
||||||
@@ -594,11 +748,13 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
def handle_item_selection(self, context, item: MenuItem):
|
def handle_item_selection(self, context, item: MenuItem):
|
||||||
"""Called when the user clicks on a menu item that doesn't represent a folder."""
|
"""Called when the user clicks on a menu item that doesn't represent a folder."""
|
||||||
|
|
||||||
|
from pillarsdk.utils import sanitize_filename
|
||||||
|
|
||||||
self.clear_images()
|
self.clear_images()
|
||||||
self._state = 'DOWNLOADING_TEXTURE'
|
self._state = 'DOWNLOADING_TEXTURE'
|
||||||
|
|
||||||
node_path_components = [node['name'] for node in self.path_stack if node is not None]
|
node_path_components = (node['name'] for node in self.path_stack if node is not None)
|
||||||
local_path_components = [self.project_uuid] + node_path_components + [self.node['name']]
|
local_path_components = [sanitize_filename(comp) for comp in node_path_components]
|
||||||
|
|
||||||
top_texture_directory = bpy.path.abspath(context.scene.local_texture_dir)
|
top_texture_directory = bpy.path.abspath(context.scene.local_texture_dir)
|
||||||
local_path = os.path.join(top_texture_directory, *local_path_components)
|
local_path = os.path.join(top_texture_directory, *local_path_components)
|
||||||
@@ -631,6 +787,13 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
|||||||
future=signalling_future))
|
future=signalling_future))
|
||||||
self.async_task.add_done_callback(texture_download_completed)
|
self.async_task.add_done_callback(texture_download_completed)
|
||||||
|
|
||||||
|
def open_browser_subscribe(self):
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
webbrowser.open_new_tab('https://cloud.blender.org/join')
|
||||||
|
|
||||||
|
self.report({'INFO'}, 'We just started a browser for you.')
|
||||||
|
|
||||||
|
|
||||||
# store keymaps here to access after registration
|
# store keymaps here to access after registration
|
||||||
addon_keymaps = []
|
addon_keymaps = []
|
||||||
@@ -644,7 +807,7 @@ def menu_draw(self, context):
|
|||||||
|
|
||||||
def register():
|
def register():
|
||||||
bpy.utils.register_class(BlenderCloudBrowser)
|
bpy.utils.register_class(BlenderCloudBrowser)
|
||||||
bpy.types.INFO_MT_mesh_add.append(menu_draw)
|
# bpy.types.INFO_MT_mesh_add.append(menu_draw)
|
||||||
|
|
||||||
# handle the keymap
|
# handle the keymap
|
||||||
wm = bpy.context.window_manager
|
wm = bpy.context.window_manager
|
||||||
|
@@ -4,6 +4,7 @@ import os
|
|||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
from contextlib import closing, contextmanager
|
from contextlib import closing, contextmanager
|
||||||
|
import pathlib
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import requests.structures
|
import requests.structures
|
||||||
@@ -14,6 +15,8 @@ from pillarsdk.utils import sanitize_filename
|
|||||||
|
|
||||||
from . import cache
|
from . import cache
|
||||||
|
|
||||||
|
SUBCLIENT_ID = 'PILLAR'
|
||||||
|
|
||||||
_pillar_api = None # will become a pillarsdk.Api object.
|
_pillar_api = None # will become a pillarsdk.Api object.
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
uncached_session = requests.session()
|
uncached_session = requests.session()
|
||||||
@@ -27,6 +30,17 @@ class UserNotLoggedInError(RuntimeError):
|
|||||||
This is basically for every interaction with Pillar.
|
This is basically for every interaction with Pillar.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.__class__.__name__
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialsNotSyncedError(UserNotLoggedInError):
|
||||||
|
"""Raised when the user may be logged in on Blender ID, but has no Blender Cloud token."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotSubscribedToCloudError(UserNotLoggedInError):
|
||||||
|
"""Raised when the user may be logged in on Blender ID, but has no Blender Cloud token."""
|
||||||
|
|
||||||
|
|
||||||
class PillarError(RuntimeError):
|
class PillarError(RuntimeError):
|
||||||
"""Raised when there is some issue with the communication with Pillar.
|
"""Raised when there is some issue with the communication with Pillar.
|
||||||
@@ -37,6 +51,35 @@ class PillarError(RuntimeError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CloudPath(pathlib.PurePosixPath):
|
||||||
|
"""Cloud path, in the form of /project uuid/node uuid/node uuid/...
|
||||||
|
|
||||||
|
The components are:
|
||||||
|
- the root '/'
|
||||||
|
- the project UUID
|
||||||
|
- zero or more node UUIDs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def project_uuid(self) -> str:
|
||||||
|
assert self.parts[0] == '/'
|
||||||
|
if len(self.parts) <= 1:
|
||||||
|
return None
|
||||||
|
return self.parts[1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def node_uuids(self) -> list:
|
||||||
|
assert self.parts[0] == '/'
|
||||||
|
return self.parts[2:]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def node_uuid(self) -> str:
|
||||||
|
if len(self.parts) <= 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.parts[-1]
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def with_existing_dir(filename: str, open_mode: str, encoding=None):
|
def with_existing_dir(filename: str, open_mode: str, encoding=None):
|
||||||
"""Opens a file, ensuring its directory exists."""
|
"""Opens a file, ensuring its directory exists."""
|
||||||
@@ -55,21 +98,15 @@ def save_as_json(pillar_resource, json_filename):
|
|||||||
json.dump(pillar_resource, outfile, sort_keys=True, cls=pillarsdk.utils.PillarJSONEncoder)
|
json.dump(pillar_resource, outfile, sort_keys=True, cls=pillarsdk.utils.PillarJSONEncoder)
|
||||||
|
|
||||||
|
|
||||||
def blender_id_profile() -> dict:
|
def blender_id_profile() -> 'blender_id.BlenderIdProfile':
|
||||||
"""Returns the Blender ID profile of the currently logged in user."""
|
"""Returns the Blender ID profile of the currently logged in user."""
|
||||||
|
|
||||||
# Allow overriding before we import the bpy module.
|
# Allow overriding before we import the bpy module.
|
||||||
if _testing_blender_id_profile is not None:
|
if _testing_blender_id_profile is not None:
|
||||||
return _testing_blender_id_profile
|
return _testing_blender_id_profile
|
||||||
|
|
||||||
import bpy
|
import blender_id
|
||||||
|
return blender_id.get_active_profile()
|
||||||
active_user_id = getattr(bpy.context.window_manager, 'blender_id_active_profile', None)
|
|
||||||
if not active_user_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
import blender_id.profiles
|
|
||||||
return blender_id.profiles.get_active_profile()
|
|
||||||
|
|
||||||
|
|
||||||
def pillar_api(pillar_endpoint: str = None) -> pillarsdk.Api:
|
def pillar_api(pillar_endpoint: str = None) -> pillarsdk.Api:
|
||||||
@@ -88,6 +125,10 @@ def pillar_api(pillar_endpoint: str = None) -> pillarsdk.Api:
|
|||||||
if not profile:
|
if not profile:
|
||||||
raise UserNotLoggedInError()
|
raise UserNotLoggedInError()
|
||||||
|
|
||||||
|
subclient = profile.subclients.get(SUBCLIENT_ID)
|
||||||
|
if not subclient:
|
||||||
|
raise CredentialsNotSyncedError()
|
||||||
|
|
||||||
if _pillar_api is None:
|
if _pillar_api is None:
|
||||||
# Allow overriding the endpoint before importing Blender-specific stuff.
|
# Allow overriding the endpoint before importing Blender-specific stuff.
|
||||||
if pillar_endpoint is None:
|
if pillar_endpoint is None:
|
||||||
@@ -97,24 +138,95 @@ def pillar_api(pillar_endpoint: str = None) -> pillarsdk.Api:
|
|||||||
pillarsdk.Api.requests_session = cache.requests_session()
|
pillarsdk.Api.requests_session = cache.requests_session()
|
||||||
|
|
||||||
_pillar_api = pillarsdk.Api(endpoint=pillar_endpoint,
|
_pillar_api = pillarsdk.Api(endpoint=pillar_endpoint,
|
||||||
username=profile['username'],
|
username=subclient['subclient_user_id'],
|
||||||
password=None,
|
password=SUBCLIENT_ID,
|
||||||
token=profile['token'])
|
token=subclient['token'])
|
||||||
|
|
||||||
return _pillar_api
|
return _pillar_api
|
||||||
|
|
||||||
|
|
||||||
|
# No more than this many Pillar calls should be made simultaneously
|
||||||
|
pillar_semaphore = asyncio.Semaphore(3)
|
||||||
|
|
||||||
|
|
||||||
|
async def pillar_call(pillar_func, *args, **kwargs):
|
||||||
|
partial = functools.partial(pillar_func, *args, api=pillar_api(), **kwargs)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
async with pillar_semaphore:
|
||||||
|
return await loop.run_in_executor(None, partial)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_pillar_credentials():
|
||||||
|
"""Tries to obtain the user at Pillar using the user's credentials.
|
||||||
|
|
||||||
|
:raises UserNotLoggedInError: when the user is not logged in on Blender ID.
|
||||||
|
:raises CredentialsNotSyncedError: when the user is logged in on Blender ID but
|
||||||
|
doesn't have a valid subclient token for Pillar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
profile = blender_id_profile()
|
||||||
|
if not profile:
|
||||||
|
raise UserNotLoggedInError()
|
||||||
|
|
||||||
|
subclient = profile.subclients.get(SUBCLIENT_ID)
|
||||||
|
if not subclient:
|
||||||
|
raise CredentialsNotSyncedError()
|
||||||
|
|
||||||
|
pillar_user_id = subclient['subclient_user_id']
|
||||||
|
if not pillar_user_id:
|
||||||
|
raise CredentialsNotSyncedError()
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_user = await pillar_call(pillarsdk.User.find, pillar_user_id)
|
||||||
|
except (pillarsdk.UnauthorizedAccess, pillarsdk.ResourceNotFound):
|
||||||
|
raise CredentialsNotSyncedError()
|
||||||
|
|
||||||
|
roles = db_user.roles
|
||||||
|
log.debug('User has roles %r', roles)
|
||||||
|
if not roles or not {'subscriber', 'demo'}.intersection(set(roles)):
|
||||||
|
# Delete the subclient info. This forces a re-check later, which can
|
||||||
|
# then pick up on the user's new status.
|
||||||
|
del profile.subclients[SUBCLIENT_ID]
|
||||||
|
profile.save_json()
|
||||||
|
raise NotSubscribedToCloudError()
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_pillar_credentials():
|
||||||
|
"""Refreshes the authentication token on Pillar.
|
||||||
|
|
||||||
|
:raises blender_id.BlenderIdCommError: when Blender ID refuses to send a token to Pillar.
|
||||||
|
:raises Exception: when the Pillar credential check fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
global _pillar_api
|
||||||
|
|
||||||
|
import blender_id
|
||||||
|
|
||||||
|
from . import blender
|
||||||
|
pillar_endpoint = blender.preferences().pillar_server.rstrip('/')
|
||||||
|
|
||||||
|
# Create a subclient token and send it to Pillar.
|
||||||
|
# May raise a blender_id.BlenderIdCommError
|
||||||
|
try:
|
||||||
|
blender_id.create_subclient_token(SUBCLIENT_ID, pillar_endpoint)
|
||||||
|
except blender_id.communication.BlenderIdCommError as ex:
|
||||||
|
log.warning("Unable to create authentication token: %s", ex)
|
||||||
|
raise CredentialsNotSyncedError()
|
||||||
|
|
||||||
|
# Test the new URL
|
||||||
|
_pillar_api = None
|
||||||
|
await check_pillar_credentials()
|
||||||
|
|
||||||
|
|
||||||
async def get_project_uuid(project_url: str) -> str:
|
async def get_project_uuid(project_url: str) -> str:
|
||||||
"""Returns the UUID for the project, given its '/p/<project_url>' string."""
|
"""Returns the UUID for the project, given its '/p/<project_url>' string."""
|
||||||
|
|
||||||
find_one = functools.partial(pillarsdk.Project.find_one, {
|
try:
|
||||||
|
project = await pillar_call(pillarsdk.Project.find_one, {
|
||||||
'where': {'url': project_url},
|
'where': {'url': project_url},
|
||||||
'projection': {'permissions': 1},
|
'projection': {'permissions': 1},
|
||||||
}, api=pillar_api())
|
})
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
try:
|
|
||||||
project = await loop.run_in_executor(None, find_one)
|
|
||||||
except pillarsdk.exceptions.ResourceNotFound:
|
except pillarsdk.exceptions.ResourceNotFound:
|
||||||
log.error('Project with URL %r does not exist', project_url)
|
log.error('Project with URL %r does not exist', project_url)
|
||||||
return None
|
return None
|
||||||
@@ -124,7 +236,7 @@ async def get_project_uuid(project_url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def get_nodes(project_uuid: str = None, parent_node_uuid: str = None,
|
async def get_nodes(project_uuid: str = None, parent_node_uuid: str = None,
|
||||||
node_type: str = None) -> list:
|
node_type = None) -> list:
|
||||||
"""Gets nodes for either a project or given a parent node.
|
"""Gets nodes for either a project or given a parent node.
|
||||||
|
|
||||||
@param project_uuid: the UUID of the project, or None if only querying by parent_node_uuid.
|
@param project_uuid: the UUID of the project, or None if only querying by parent_node_uuid.
|
||||||
@@ -149,19 +261,31 @@ async def get_nodes(project_uuid: str = None, parent_node_uuid: str = None,
|
|||||||
where['project'] = project_uuid
|
where['project'] = project_uuid
|
||||||
|
|
||||||
if node_type:
|
if node_type:
|
||||||
|
if isinstance(node_type, str):
|
||||||
where['node_type'] = node_type
|
where['node_type'] = node_type
|
||||||
|
else:
|
||||||
|
where['node_type'] = {'$in': node_type}
|
||||||
|
|
||||||
node_all = functools.partial(pillarsdk.Node.all, {
|
children = await pillar_call(pillarsdk.Node.all, {
|
||||||
'projection': {'name': 1, 'parent': 1, 'node_type': 1,
|
'projection': {'name': 1, 'parent': 1, 'node_type': 1,
|
||||||
'properties.order': 1, 'properties.status': 1,
|
'properties.order': 1, 'properties.status': 1,
|
||||||
'properties.files': 1,
|
'properties.files': 1,
|
||||||
'properties.content_type': 1, 'picture': 1},
|
'properties.content_type': 1, 'picture': 1},
|
||||||
'where': where,
|
'where': where,
|
||||||
'sort': 'properties.order',
|
'embed': ['parent']})
|
||||||
'embed': ['parent']}, api=pillar_api())
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
return children['_items']
|
||||||
children = await loop.run_in_executor(None, node_all)
|
|
||||||
|
|
||||||
|
async def get_texture_projects() -> list:
|
||||||
|
"""Returns project dicts that contain textures."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
children = await pillar_call(pillarsdk.Project.all_from_endpoint,
|
||||||
|
'/bcloud/texture-libraries')
|
||||||
|
except pillarsdk.ResourceNotFound as ex:
|
||||||
|
log.warning('Unable to find texture projects: %s', ex)
|
||||||
|
raise PillarError('Unable to find texture projects: %s' % ex)
|
||||||
|
|
||||||
return children['_items']
|
return children['_items']
|
||||||
|
|
||||||
@@ -277,11 +401,7 @@ async def fetch_thumbnail_info(file: pillarsdk.File, directory: str, desired_siz
|
|||||||
finished.
|
finished.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
api = pillar_api()
|
thumb_link = await pillar_call(file.thumbnail, desired_size)
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
thumb_link = await loop.run_in_executor(None, functools.partial(
|
|
||||||
file.thumbnail_file, desired_size, api=api))
|
|
||||||
|
|
||||||
if thumb_link is None:
|
if thumb_link is None:
|
||||||
raise ValueError("File {} has no thumbnail of size {}"
|
raise ValueError("File {} has no thumbnail of size {}"
|
||||||
@@ -323,21 +443,12 @@ async def fetch_texture_thumbs(parent_node_uuid: str, desired_size: str,
|
|||||||
log.warning('fetch_texture_thumbs: Texture downloading cancelled')
|
log.warning('fetch_texture_thumbs: Texture downloading cancelled')
|
||||||
return
|
return
|
||||||
|
|
||||||
# We don't want to gather too much in parallel, as it will make cancelling take more time.
|
|
||||||
# This is caused by HTTP requests going out in parallel, and once the socket is open and
|
|
||||||
# the GET request is sent, we can't cancel until the server starts streaming the response.
|
|
||||||
chunk_size = 2
|
|
||||||
for i in range(0, len(texture_nodes), chunk_size):
|
|
||||||
chunk = texture_nodes[i:i + chunk_size]
|
|
||||||
|
|
||||||
log.debug('fetch_texture_thumbs: Gathering texture[%i:%i] for parent node %r',
|
|
||||||
i, i + chunk_size, parent_node_uuid)
|
|
||||||
coros = (download_texture_thumbnail(texture_node, desired_size,
|
coros = (download_texture_thumbnail(texture_node, desired_size,
|
||||||
thumbnail_directory,
|
thumbnail_directory,
|
||||||
thumbnail_loading=thumbnail_loading,
|
thumbnail_loading=thumbnail_loading,
|
||||||
thumbnail_loaded=thumbnail_loaded,
|
thumbnail_loaded=thumbnail_loaded,
|
||||||
future=future)
|
future=future)
|
||||||
for texture_node in chunk)
|
for texture_node in texture_nodes)
|
||||||
|
|
||||||
# raises any exception from failed handle_texture_node() calls.
|
# raises any exception from failed handle_texture_node() calls.
|
||||||
await asyncio.gather(*coros)
|
await asyncio.gather(*coros)
|
||||||
@@ -360,17 +471,28 @@ async def download_texture_thumbnail(texture_node, desired_size: str,
|
|||||||
texture_node['_id'])
|
texture_node['_id'])
|
||||||
return
|
return
|
||||||
|
|
||||||
api = pillar_api()
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
file_find = functools.partial(pillarsdk.File.find, params={
|
# Find out which file to use for the thumbnail picture.
|
||||||
'projection': {'filename': 1, 'variations': 1, 'width': 1, 'height': 1},
|
pic_uuid = texture_node.picture
|
||||||
}, api=api)
|
if not pic_uuid:
|
||||||
|
# Fall back to the first texture file, if it exists.
|
||||||
|
log.debug('Node %r does not have a picture, falling back to first file.',
|
||||||
|
texture_node['_id'])
|
||||||
|
files = texture_node.properties and texture_node.properties.files
|
||||||
|
if not files:
|
||||||
|
log.info('Node %r does not have a picture nor files, skipping.', texture_node['_id'])
|
||||||
|
return
|
||||||
|
pic_uuid = files[0].file
|
||||||
|
if not pic_uuid:
|
||||||
|
log.info('Node %r does not have a picture nor files, skipping.', texture_node['_id'])
|
||||||
|
return
|
||||||
|
|
||||||
# Find the File that belongs to this texture node
|
# Load the File that belongs to this texture node's picture.
|
||||||
pic_uuid = texture_node['picture']
|
|
||||||
loop.call_soon_threadsafe(thumbnail_loading, texture_node, texture_node)
|
loop.call_soon_threadsafe(thumbnail_loading, texture_node, texture_node)
|
||||||
file_desc = await loop.run_in_executor(None, file_find, pic_uuid)
|
file_desc = await pillar_call(pillarsdk.File.find, pic_uuid, params={
|
||||||
|
'projection': {'filename': 1, 'variations': 1, 'width': 1, 'height': 1},
|
||||||
|
})
|
||||||
|
|
||||||
if file_desc is None:
|
if file_desc is None:
|
||||||
log.warning('Unable to find file for texture node %s', pic_uuid)
|
log.warning('Unable to find file for texture node %s', pic_uuid)
|
||||||
@@ -413,18 +535,21 @@ async def download_file_by_uuid(file_uuid,
|
|||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
# Find the File document.
|
# Find the File document.
|
||||||
api = pillar_api()
|
file_desc = await pillar_call(pillarsdk.File.find, file_uuid, params={
|
||||||
file_find = functools.partial(pillarsdk.File.find, params={
|
|
||||||
'projection': {'link': 1, 'filename': 1},
|
'projection': {'link': 1, 'filename': 1},
|
||||||
}, api=api)
|
})
|
||||||
file_desc = await loop.run_in_executor(None, file_find, file_uuid)
|
|
||||||
|
|
||||||
# Save the file document to disk
|
# Save the file document to disk
|
||||||
metadata_file = os.path.join(metadata_directory, 'files', '%s.json' % file_uuid)
|
metadata_file = os.path.join(metadata_directory, 'files', '%s.json' % file_uuid)
|
||||||
save_as_json(file_desc, metadata_file)
|
save_as_json(file_desc, metadata_file)
|
||||||
|
|
||||||
file_path = os.path.join(target_directory,
|
root, ext = os.path.splitext(file_desc['filename'])
|
||||||
sanitize_filename('%s-%s' % (map_type, file_desc['filename'])))
|
if root.endswith(map_type):
|
||||||
|
target_filename = '%s%s' % (root, ext)
|
||||||
|
else:
|
||||||
|
target_filename = '%s-%s%s' % (root, map_type, ext)
|
||||||
|
|
||||||
|
file_path = os.path.join(target_directory, sanitize_filename(target_filename))
|
||||||
file_url = file_desc['link']
|
file_url = file_desc['link']
|
||||||
# log.debug('Texture %r:\n%s', file_uuid, pprint.pformat(file_desc.to_dict()))
|
# log.debug('Texture %r:\n%s', file_uuid, pprint.pformat(file_desc.to_dict()))
|
||||||
loop.call_soon_threadsafe(file_loading, file_path, file_desc)
|
loop.call_soon_threadsafe(file_loading, file_path, file_desc)
|
||||||
@@ -458,7 +583,7 @@ async def download_texture(texture_node,
|
|||||||
future=future)
|
future=future)
|
||||||
for file_info in texture_node['properties']['files'])
|
for file_info in texture_node['properties']['files'])
|
||||||
|
|
||||||
return await asyncio.gather(*downloaders)
|
return await asyncio.gather(*downloaders, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
def is_cancelled(future: asyncio.Future) -> bool:
|
def is_cancelled(future: asyncio.Future) -> bool:
|
||||||
|
@@ -18,8 +18,9 @@ def load_wheel(module_name, fname_prefix):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
module = __import__(module_name)
|
module = __import__(module_name)
|
||||||
except ImportError:
|
except ImportError as ex:
|
||||||
pass
|
log.debug('Unable to import %s directly, will try wheel: %s',
|
||||||
|
module_name, ex)
|
||||||
else:
|
else:
|
||||||
log.debug('Was able to load %s from %s, no need to load wheel %s',
|
log.debug('Was able to load %s from %s, no need to load wheel %s',
|
||||||
module_name, module.__file__, fname_prefix)
|
module_name, module.__file__, fname_prefix)
|
||||||
@@ -38,4 +39,4 @@ def load_wheel(module_name, fname_prefix):
|
|||||||
def load_wheels():
|
def load_wheels():
|
||||||
load_wheel('lockfile', 'lockfile')
|
load_wheel('lockfile', 'lockfile')
|
||||||
load_wheel('cachecontrol', 'CacheControl')
|
load_wheel('cachecontrol', 'CacheControl')
|
||||||
load_wheel('pillarsdk', 'pillar_sdk')
|
load_wheel('pillarsdk', 'pillarsdk')
|
||||||
|
@@ -1,2 +1,15 @@
|
|||||||
|
# Primary requirements:
|
||||||
CacheControl==0.11.6
|
CacheControl==0.11.6
|
||||||
lockfile==0.12.2
|
lockfile==0.12.2
|
||||||
|
pillarsdk==1.2.0
|
||||||
|
wheel==0.29.0
|
||||||
|
|
||||||
|
# Secondary requirements:
|
||||||
|
cffi==1.6.0
|
||||||
|
cryptography==1.3.1
|
||||||
|
idna==2.1
|
||||||
|
pyasn1==0.1.9
|
||||||
|
pycparser==2.14
|
||||||
|
pyOpenSSL==16.0.0
|
||||||
|
requests==2.10.0
|
||||||
|
six==1.10.0
|
||||||
|
58
setup.py
58
setup.py
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
import glob
|
||||||
import sys
|
import sys
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import re
|
import re
|
||||||
import pathlib
|
import pathlib
|
||||||
from glob import glob
|
|
||||||
|
|
||||||
from distutils import log
|
from distutils import log
|
||||||
from distutils.core import Command
|
from distutils.core import Command
|
||||||
@@ -16,6 +16,15 @@ from setuptools import setup, find_packages
|
|||||||
requirement_re = re.compile('[><=]+')
|
requirement_re = re.compile('[><=]+')
|
||||||
|
|
||||||
|
|
||||||
|
def set_default_path(var, default):
|
||||||
|
"""convert CLI-arguments (string) to Paths"""
|
||||||
|
|
||||||
|
if var is None:
|
||||||
|
return default
|
||||||
|
return pathlib.Path(var)
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyAttributeOutsideInit
|
||||||
class BuildWheels(Command):
|
class BuildWheels(Command):
|
||||||
"""Builds or downloads the dependencies as wheel files."""
|
"""Builds or downloads the dependencies as wheel files."""
|
||||||
|
|
||||||
@@ -23,30 +32,21 @@ class BuildWheels(Command):
|
|||||||
user_options = [
|
user_options = [
|
||||||
('wheels-path=', None, "wheel file installation path"),
|
('wheels-path=', None, "wheel file installation path"),
|
||||||
('deps-path=', None, "path in which dependencies are built"),
|
('deps-path=', None, "path in which dependencies are built"),
|
||||||
('pillar-sdk-path=', None, "subdir of deps-path containing the Pillar Python SDK"),
|
|
||||||
('cachecontrol-path=', None, "subdir of deps-path containing CacheControl"),
|
('cachecontrol-path=', None, "subdir of deps-path containing CacheControl"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def initialize_options(self):
|
def initialize_options(self):
|
||||||
self.wheels_path = None # path that will contain the installed wheels.
|
self.wheels_path = None # path that will contain the installed wheels.
|
||||||
self.deps_path = None # path in which dependencies are built.
|
self.deps_path = None # path in which dependencies are built.
|
||||||
self.pillar_sdk_path = None # subdir of deps_path containing the Pillar Python SDK
|
|
||||||
self.cachecontrol_path = None # subdir of deps_path containing CacheControl
|
self.cachecontrol_path = None # subdir of deps_path containing CacheControl
|
||||||
|
|
||||||
def finalize_options(self):
|
def finalize_options(self):
|
||||||
self.my_path = pathlib.Path(__file__).resolve().parent
|
self.my_path = pathlib.Path(__file__).resolve().parent
|
||||||
package_path = self.my_path / self.distribution.get_name()
|
package_path = self.my_path / self.distribution.get_name()
|
||||||
|
|
||||||
def set_default(var, default):
|
self.wheels_path = set_default_path(self.wheels_path, package_path / 'wheels')
|
||||||
if var is None:
|
self.deps_path = set_default_path(self.deps_path, self.my_path / 'build/deps')
|
||||||
return default
|
self.cachecontrol_path = set_default_path(self.cachecontrol_path,
|
||||||
return pathlib.Path(var) # convert CLI-arguments (string) to Paths.
|
|
||||||
|
|
||||||
self.wheels_path = set_default(self.wheels_path, package_path / 'wheels')
|
|
||||||
self.deps_path = set_default(self.deps_path, self.my_path / 'build/deps')
|
|
||||||
self.pillar_sdk_path = set_default(self.pillar_sdk_path,
|
|
||||||
self.deps_path / 'pillar-python-sdk')
|
|
||||||
self.cachecontrol_path = set_default(self.cachecontrol_path,
|
|
||||||
self.deps_path / 'cachecontrol')
|
self.deps_path / 'cachecontrol')
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
@@ -73,16 +73,12 @@ class BuildWheels(Command):
|
|||||||
# Download lockfile, as there is a suitable wheel on pypi.
|
# Download lockfile, as there is a suitable wheel on pypi.
|
||||||
if not list(self.wheels_path.glob('lockfile*.whl')):
|
if not list(self.wheels_path.glob('lockfile*.whl')):
|
||||||
log.info('Downloading lockfile wheel')
|
log.info('Downloading lockfile wheel')
|
||||||
subprocess.check_call([
|
self.download_wheel(requirements['lockfile'])
|
||||||
'pip', 'download', '--dest', str(self.wheels_path), requirements['lockfile'][0]
|
|
||||||
])
|
|
||||||
|
|
||||||
# Build Pillar Python SDK.
|
# Download Pillar Python SDK from pypi.
|
||||||
if not list(self.wheels_path.glob('pillar-python-sdk*.whl')):
|
if not list(self.wheels_path.glob('pillarsdk*.whl')):
|
||||||
log.info('Building Pillar Python SDK in %s', self.pillar_sdk_path)
|
log.info('Downloading Pillar Python SDK wheel')
|
||||||
self.git_clone(self.pillar_sdk_path,
|
self.download_wheel(requirements['pillarsdk'])
|
||||||
'https://github.com/armadillica/pillar-python-sdk.git')
|
|
||||||
self.build_copy_wheel(self.pillar_sdk_path)
|
|
||||||
|
|
||||||
# Build CacheControl.
|
# Build CacheControl.
|
||||||
if not list(self.wheels_path.glob('CacheControl*.whl')):
|
if not list(self.wheels_path.glob('CacheControl*.whl')):
|
||||||
@@ -97,6 +93,16 @@ class BuildWheels(Command):
|
|||||||
('blender_cloud/wheels', (str(p) for p in self.wheels_path.glob('*.whl')))
|
('blender_cloud/wheels', (str(p) for p in self.wheels_path.glob('*.whl')))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def download_wheel(self, requirement):
|
||||||
|
"""Downloads a wheel from PyPI and saves it in self.wheels_path."""
|
||||||
|
|
||||||
|
subprocess.check_call([
|
||||||
|
'pip', 'download',
|
||||||
|
'--no-deps',
|
||||||
|
'--dest', str(self.wheels_path),
|
||||||
|
requirement[0]
|
||||||
|
])
|
||||||
|
|
||||||
def git_clone(self, workdir: pathlib.Path, git_url: str, checkout: str = None):
|
def git_clone(self, workdir: pathlib.Path, git_url: str, checkout: str = None):
|
||||||
if workdir.exists():
|
if workdir.exists():
|
||||||
# Directory exists, expect it to be set up correctly.
|
# Directory exists, expect it to be set up correctly.
|
||||||
@@ -124,6 +130,8 @@ class BuildWheels(Command):
|
|||||||
log.info('copying %s to %s', wheel, self.wheels_path)
|
log.info('copying %s to %s', wheel, self.wheels_path)
|
||||||
shutil.copy(str(wheel), str(self.wheels_path))
|
shutil.copy(str(wheel), str(self.wheels_path))
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyAttributeOutsideInit
|
||||||
class BlenderAddonBdist(bdist):
|
class BlenderAddonBdist(bdist):
|
||||||
"""Ensures that 'python setup.py bdist' creates a zip file."""
|
"""Ensures that 'python setup.py bdist' creates a zip file."""
|
||||||
|
|
||||||
@@ -137,6 +145,7 @@ class BlenderAddonBdist(bdist):
|
|||||||
super().run()
|
super().run()
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyAttributeOutsideInit
|
||||||
class BlenderAddonInstall(install):
|
class BlenderAddonInstall(install):
|
||||||
"""Ensures the module is placed at the root of the zip file."""
|
"""Ensures the module is placed at the root of the zip file."""
|
||||||
|
|
||||||
@@ -164,11 +173,12 @@ setup(
|
|||||||
'wheels': BuildWheels},
|
'wheels': BuildWheels},
|
||||||
name='blender_cloud',
|
name='blender_cloud',
|
||||||
description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.',
|
description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.',
|
||||||
version='1.0.0',
|
version='1.1.0',
|
||||||
author='Sybren A. Stüvel',
|
author='Sybren A. Stüvel',
|
||||||
author_email='sybren@stuvel.eu',
|
author_email='sybren@stuvel.eu',
|
||||||
packages=find_packages('.'),
|
packages=find_packages('.'),
|
||||||
data_files=[('blender_cloud', ['README.md'])],
|
data_files=[('blender_cloud', ['README.md']),
|
||||||
|
('blender_cloud/icons', glob.glob('blender_cloud/icons/*'))],
|
||||||
scripts=[],
|
scripts=[],
|
||||||
url='https://developer.blender.org/diffusion/BCA/',
|
url='https://developer.blender.org/diffusion/BCA/',
|
||||||
license='GNU General Public License v2 or later (GPLv2+)',
|
license='GNU General Public License v2 or later (GPLv2+)',
|
||||||
|
Reference in New Issue
Block a user