diff --git a/blender_cloud/__init__.py b/blender_cloud/__init__.py index bc33790..67840c7 100644 --- a/blender_cloud/__init__.py +++ b/blender_cloud/__init__.py @@ -21,7 +21,7 @@ bl_info = { "name": "Blender Cloud Texture Browser", "author": "Sybren A. Stüvel and Francesco Siddi", - "version": (0, 1, 0), + "version": (0, 2, 0), "blender": (2, 77, 0), "location": "TO BE DETERMINED", "description": "Allows downloading of textures from the Blender Cloud. Requires " @@ -32,23 +32,19 @@ bl_info = { "support": "TESTING" } -import os.path -import typing -import asyncio - # Support reloading if 'pillar' in locals(): import importlib pillar = importlib.reload(pillar) async_loop = importlib.reload(async_loop) + gui = importlib.reload(gui) else: - from . import pillar, async_loop + from . import pillar, async_loop, gui import bpy -import bpy.utils.previews -from bpy.types import AddonPreferences, Operator, PropertyGroup, WindowManager -from bpy.props import PointerProperty, StringProperty, EnumProperty +from bpy.types import AddonPreferences, Operator, WindowManager +from bpy.props import StringProperty class BlenderCloudPreferences(AddonPreferences): @@ -132,134 +128,9 @@ class PillarCredentialsUpdate(Operator): return {'FINISHED'} -# We can store multiple preview collections here, -# however in this example we only store "main" -preview_collections = {} - - -def enum_previews_from_directory_items(self, context) -> typing.List[typing.AnyStr]: - """EnumProperty callback""" - - if context is None: - return [] - - wm = context.window_manager - project_uuid = wm.blender_cloud_project - node_uuid = wm.blender_cloud_node - - # Get the preview collection (defined in register func). - pcoll = preview_collections["blender_cloud"] - if pcoll.project_uuid == project_uuid and pcoll.node_uuid == node_uuid: - return pcoll.previews - - print('Loading previews for project {!r} node {!r}'.format(project_uuid, node_uuid)) - - if pcoll.async_task is not None and not pcoll.async_task.done(): - # We're still asynchronously downloading, but the UUIDs changed. - print('Cancelling running async download task {}'.format(pcoll.async_task)) - pcoll.async_task.cancel() - - # Download the previews asynchronously. - pcoll.previews = [] - pcoll.project_uuid = project_uuid - pcoll.node_uuid = node_uuid - pcoll.async_task = asyncio.ensure_future(async_download_previews(wm.thumbnails_cache, pcoll)) - - # Start the async manager so everything happens. - async_loop.ensure_async_loop() - - return pcoll.previews - - -async def async_download_previews(thumbnails_directory, pcoll): - # If we have a node UUID, we fetch the textures - # FIXME: support mixture of sub-nodes and textures under one node. - enum_items = pcoll.previews - - node_uuid = pcoll.node_uuid - project_uuid = pcoll.project_uuid - - def thumbnail_loading(file_desc): - # TODO: trigger re-draw - pass - - def thumbnail_loaded(file_desc, thumb_path): - thumb = pcoll.get(thumb_path) - if thumb is None: - thumb = pcoll.load(thumb_path, thumb_path, 'IMAGE') - enum_items.append(('thumb-{}'.format(thumb_path), file_desc['filename'], - thumb_path, - thumb.icon_id, - len(enum_items))) - # TODO: trigger re-draw - - if node_uuid: - # Make sure we can go up again. - parent = await pillar.parent_node_uuid(node_uuid) - enum_items.append(('node-{}'.format(parent), 'up', 'up', - 'FILE_FOLDER', - len(enum_items))) - - directory = os.path.join(thumbnails_directory, project_uuid, node_uuid) - os.makedirs(directory, exist_ok=True) - - await pillar.fetch_texture_thumbs(node_uuid, 's', directory, - thumbnail_loading=thumbnail_loading, - thumbnail_loaded=thumbnail_loaded) - elif project_uuid: - children = await pillar.get_nodes(project_uuid, '') - - for child in children: - print(' - %(_id)s = %(name)s' % child) - enum_items.append(('node-{}'.format(child['_id']), child['name'], - 'description', - 'FILE_FOLDER', - len(enum_items))) - # TODO: trigger re-draw - else: - # TODO: add "nothing here" icon and trigger re-draw - pass - - -def enum_previews_from_directory_update(self, context): - print('Updating from {!r}'.format(self.blender_cloud_thumbnails)) - - sel_type, sel_id = self.blender_cloud_thumbnails.split('-', 1) - - if sel_type == 'node': - # Go into this node - self.blender_cloud_node = sel_id - elif sel_type == 'thumb': - # Select this image - pass - else: - print("enum_previews_from_directory_update: Don't know what to do with {!r}" - .format(self.blender_cloud_thumbnails)) - - -class PreviewsExamplePanel(bpy.types.Panel): - """Creates a Panel in the Object properties window""" - - bl_label = "Previews Example Panel" - bl_idname = "OBJECT_PT_previews" - bl_space_type = 'PROPERTIES' - bl_region_type = 'WINDOW' - bl_context = "object" - - def draw(self, context): - layout = self.layout - wm = context.window_manager - - row = layout.column() - row.prop(wm, "thumbnails_cache") - row.prop(wm, "blender_cloud_project") - row.prop(wm, "blender_cloud_node") - row.template_icon_view(wm, "blender_cloud_thumbnails", show_labels=True) - # row.prop(wm, "blender_cloud_thumbnails") - - def register(): - bpy.utils.register_module(__name__) + bpy.utils.register_class(BlenderCloudPreferences) + bpy.utils.register_class(PillarCredentialsUpdate) WindowManager.thumbnails_cache = StringProperty( name="Thumbnails cache", @@ -274,40 +145,19 @@ def register(): name="Blender Cloud node UUID", default='') # empty == top-level of project - WindowManager.blender_cloud_thumbnails = EnumProperty( - items=enum_previews_from_directory_items, - update=enum_previews_from_directory_update, - ) - - # Note that preview collections returned by bpy.utils.previews - # are regular Python objects - you can use them to store custom data. - # - # This is especially useful here, since: - # - It avoids us regenerating the whole enum over and over. - # - It can store enum_items' strings - # (remember you have to keep those strings somewhere in py, - # else they get freed and Blender references invalid memory!). - pcoll = bpy.utils.previews.new() - pcoll.previews = () - pcoll.project_uuid = '' - pcoll.node_uuid = '' - pcoll.async_task = None - - preview_collections["blender_cloud"] = pcoll + gui.register() def unregister(): - bpy.utils.unregister_module(__name__) + gui.unregister() + + bpy.utils.unregister_class(PillarCredentialsUpdate) + bpy.utils.unregister_class(BlenderCloudPreferences) - del WindowManager.thumbnails_cache del WindowManager.blender_cloud_project del WindowManager.blender_cloud_node del WindowManager.blender_cloud_thumbnails - for pcoll in preview_collections.values(): - bpy.utils.previews.remove(pcoll) - preview_collections.clear() - if __name__ == "__main__": register() diff --git a/blender_cloud/async_loop.py b/blender_cloud/async_loop.py index eb1d4da..53ba39f 100644 --- a/blender_cloud/async_loop.py +++ b/blender_cloud/async_loop.py @@ -1,6 +1,8 @@ """Manages the asyncio loop.""" import asyncio +import traceback + import bpy @@ -12,11 +14,27 @@ def kick_async_loop(*args): stop_async_loop() return - if not asyncio.Task.all_tasks(): + all_tasks = asyncio.Task.all_tasks() + if not all_tasks: print('{}: no more scheduled tasks, stopping'.format(__name__)) stop_async_loop() return + if all(task.done() for task in all_tasks): + print('{}: all tasks are done, fetching results and stopping.'.format(__name__)) + for task in all_tasks: + # noinspection PyBroadException + try: + task.result() + except asyncio.CancelledError: + # No problem, we want to stop anyway. + pass + except Exception: + print('{}: resulted in exception'.format(task)) + traceback.print_exc() + stop_async_loop() + return + # Perform a single async loop step async def do_nothing(): pass @@ -43,7 +61,3 @@ def stop_async_loop(): if handler is None: return bpy.app.handlers.scene_update_pre.remove(handler) - - # Optional: cancel all pending tasks. - # for task in asyncio.Task.all_tasks(): - # task.cancel() diff --git a/blender_cloud/gui.py b/blender_cloud/gui.py new file mode 100644 index 0000000..dcaf566 --- /dev/null +++ b/blender_cloud/gui.py @@ -0,0 +1,414 @@ +# ##### 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 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 . +# +# ##### END GPL LICENSE BLOCK ##### +import asyncio +import threading + +import bpy +import bgl +import blf +import os + +from bpy.types import AddonPreferences +from bpy.props import (BoolProperty, EnumProperty, + FloatProperty, FloatVectorProperty, + IntProperty, StringProperty) + +from . import async_loop, pillar + +icon_width = 128 +icon_height = 128 +target_item_width = 400 +target_item_height = 128 + +library_path = '/tmp' +library_icons_path = os.path.join(os.path.dirname(__file__), "icons") + + +class MenuItem: + """GUI menu item for the 3D View GUI.""" + + icon_margin_x = 4 + icon_margin_y = 4 + text_margin_x = 6 + + text_height = 16 + text_width = 72 + + DEFAULT_ICONS = { + 'FOLDER': os.path.join(library_icons_path, 'folder.png'), + } + + def __init__(self, node_uuid: str, file_desc, thumb_path: str, label_text): + self.node_uuid = node_uuid # pillarsdk.Node UUID + self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node. + self.label_text = label_text + + thumb_path = self.DEFAULT_ICONS.get(thumb_path, thumb_path) + self.thumb_path = thumb_path + self.icon = bpy.data.images.load(filepath=thumb_path) + + # Updated when drawing the image + self.x = 0 + self.y = 0 + self.width = 0 + self.height = 0 + + @property + def is_folder(self) -> bool: + return self.file_desc is None + + def update_placement(self, x, y, width, height): + """Use OpenGL to draw this one menu item.""" + + self.x = x + self.y = y + self.width = width + self.height = height + + def draw(self, highlighted: bool): + bgl.glEnable(bgl.GL_BLEND) + if highlighted: + bgl.glColor4f(0.555, 0.555, 0.555, 0.8) + else: + bgl.glColor4f(0.447, 0.447, 0.447, 0.8) + + bgl.glRectf(self.x, self.y, self.x + self.width, self.y + self.height) + + texture = self.icon + err = texture.gl_load(filter=bgl.GL_NEAREST, mag=bgl.GL_NEAREST) + assert not err, 'OpenGL error: %i' % err + + bgl.glColor4f(0.0, 0.0, 1.0, 0.5) + # bgl.glLineWidth(1.5) + + # ------ TEXTURE ---------# + bgl.glBindTexture(bgl.GL_TEXTURE_2D, texture.bindcode[0]) + bgl.glEnable(bgl.GL_TEXTURE_2D) + bgl.glBlendFunc(bgl.GL_SRC_ALPHA, bgl.GL_ONE_MINUS_SRC_ALPHA) + + bgl.glColor4f(1, 1, 1, 1) + bgl.glBegin(bgl.GL_QUADS) + bgl.glTexCoord2d(0, 0) + bgl.glVertex2d(self.x + self.icon_margin_x, self.y) + bgl.glTexCoord2d(0, 1) + bgl.glVertex2d(self.x + self.icon_margin_x, self.y + icon_height) + bgl.glTexCoord2d(1, 1) + bgl.glVertex2d(self.x + self.icon_margin_x + icon_width, self.y + icon_height) + bgl.glTexCoord2d(1, 0) + bgl.glVertex2d(self.x + self.icon_margin_x + icon_width, self.y) + bgl.glEnd() + bgl.glDisable(bgl.GL_TEXTURE_2D) + bgl.glDisable(bgl.GL_BLEND) + + texture.gl_free() + + # draw some text + font_id = 0 + blf.position(font_id, + self.x + self.icon_margin_x + icon_width + self.text_margin_x, + self.y + icon_height * 0.5 - 0.25 * self.text_height, 0) + blf.size(font_id, self.text_height, self.text_width) + blf.draw(font_id, self.label_text) + + def hits(self, mouse_x: int, mouse_y: int) -> bool: + return self.x < mouse_x < self.x + self.width and self.y < mouse_y < self.y + self.height + + +class BlenderCloudBrowser(bpy.types.Operator): + bl_idname = 'pillar.browser' + bl_label = 'Blender Cloud Texture Browser' + + _draw_handle = None + + project_uuid = '5672beecc0261b2005ed1a33' # Blender Cloud project UUID + node_uuid = '' # Blender Cloud node UUID + async_task = None # asyncio task for fetching thumbnails + timer = None + + current_path = '' + current_display_content = [] + loaded_images = set() + thumbnails_cache = '' + + mouse_x = 0 + mouse_y = 0 + + def invoke(self, context, event): + if context.area.type != 'VIEW_3D': + self.report({'WARNING'}, "View3D not found, cannot show asset flinger") + return {'CANCELLED'} + + print('Area is %s' % context.area) + + wm = context.window_manager + self.thumbnails_cache = wm.thumbnails_cache + self.project_uuid = wm.blender_cloud_project + self.node_uuid = wm.blender_cloud_node + + self.mouse_x = event.mouse_region_x + self.mouse_y = event.mouse_region_y + + # Add the region OpenGL drawing callback + # draw in view space with 'POST_VIEW' and 'PRE_VIEW' + self._draw_handle = bpy.types.SpaceView3D.draw_handler_add( + self.draw_menu, (context,), 'WINDOW', 'POST_PIXEL') + + self.current_display_content = [] + self.loaded_images = set() + self.browse_assets(context) + + context.window_manager.modal_handler_add(self) + self.timer = context.window_manager.event_timer_add(1/30, context.window) + + return {'RUNNING_MODAL'} + + def modal(self, context, event): + if event.type == 'TIMER': + context.area.tag_redraw() + return {'RUNNING_MODAL'} + + if 'MOUSE' in event.type: + context.area.tag_redraw() + self.mouse_x = event.mouse_region_x + self.mouse_y = event.mouse_region_y + + if event.type == 'LEFTMOUSE' and event.value == 'RELEASE': + selected = self.get_clicked() + + if selected is None: + self._finish(context) + return {'FINISHED'} + + if selected.is_folder: + self.node_uuid = selected.node_uuid + self.browse_assets(context) + else: + self.handle_item_selection(selected) + self._finish(context) + return {'FINISHED'} + + elif event.type in {'RIGHTMOUSE', 'ESC'}: + self._finish(context) + return {'CANCELLED'} + + return {'RUNNING_MODAL'} + + def _stop_async_task(self): + if self.async_task is None: + return + + if not self.async_task.done(): + print('Cancelling running async download task {}'.format(self.async_task)) + self.async_task.cancel() + else: + self.async_task.result() # This re-raises any exception of the task. + + def _finish(self, context): + self._stop_async_task() + bpy.types.SpaceView3D.draw_handler_remove(self._draw_handle, 'WINDOW') + context.window_manager.event_timer_remove(self.timer) + context.area.tag_redraw() + + def clear_images(self): + """Removes all images we loaded from Blender's memory.""" + + for image in bpy.data.images: + if image.filepath_raw not in self.loaded_images: + continue + + image.user_clear() + bpy.data.images.remove(image) + + self.loaded_images.clear() + self.current_display_content.clear() + + def add_menu_item(self, *args, menu_item_lock=threading.Lock()) -> MenuItem: + menu_item = MenuItem(*args) + + # Just make this thread-safe to be on the safe side. + with menu_item_lock: + self.current_display_content.append(menu_item) + self.loaded_images.add(menu_item.icon.filepath_raw) + + return menu_item + + async def async_download_previews(self, context, thumbnails_directory): + # If we have a node UUID, we fetch the textures + # FIXME: support mixture of sub-nodes and textures under one node. + self.clear_images() + + def redraw(): + # region = context.region + # if region is None: + # print('Unable to redraw, region is %s' % region) + # print(' (context is %s)' % context) + # return + # region.tag_redraw() + pass + + def thumbnail_loading(node_uuid, file_desc): + # TODO: add MenuItem + redraw() + + def thumbnail_loaded(node_uuid, file_desc, thumb_path): + # Add MenuItem; TODO: update MenuItem added above + self.add_menu_item(node_uuid, file_desc, thumb_path, file_desc['filename']) + redraw() + + if self.node_uuid: + # Make sure we can go up again. + parent_uuid = await pillar.parent_node_uuid(self.node_uuid) + self.add_menu_item(parent_uuid, None, 'FOLDER', '.. up ..') + + directory = os.path.join(thumbnails_directory, self.project_uuid, self.node_uuid) + os.makedirs(directory, exist_ok=True) + + await pillar.fetch_texture_thumbs(self.node_uuid, 's', directory, + thumbnail_loading=thumbnail_loading, + thumbnail_loaded=thumbnail_loaded) + elif self.project_uuid: + children = await pillar.get_nodes(self.project_uuid, '') + + for child in children: + print(' - %(_id)s = %(name)s' % child) + self.add_menu_item(child['_id'], None, 'FOLDER', child['name']) + redraw() + else: + # TODO: add "nothing here" icon and trigger re-draw + redraw() + + # Call the 'done' callback. + loop = asyncio.get_event_loop() + loop.call_soon_threadsafe(self.downloading_done) + + def browse_assets(self, context): + self._stop_async_task() + self.clear_images() + + # Download the previews asynchronously. + self.async_task = asyncio.ensure_future( + self.async_download_previews(context, self.thumbnails_cache)) + + # Start the async manager so everything happens. + async_loop.ensure_async_loop() + + def downloading_done(self): + # if not self.async_task.done(): + # print('%s: aborting download task' % self) + # self._stop_async_task() + # else: + # print('%s: downloading done' % self) + # self.async_task.result() + pass + + def draw_menu(self, context): + margin_x = 20 + margin_y = 5 + padding_x = 5 + + content_width = context.area.regions[4].width - margin_x * 2 + content_height = context.area.regions[4].height - margin_y * 2 + + content_x = margin_x + content_y = context.area.height - margin_y - target_item_height - 50 + + col_count = content_width // target_item_width + + item_width = (content_width - (col_count * padding_x)) / col_count + item_height = target_item_height + + block_width = item_width + padding_x + block_height = item_height + margin_y + + bgl.glEnable(bgl.GL_BLEND) + bgl.glColor4f(0.0, 0.0, 0.0, 0.6) + bgl.glRectf(0, 0, context.area.regions[4].width, context.area.regions[4].height) + + if self.current_display_content: + for item_idx, item in enumerate(self.current_display_content): + x = (item_idx % col_count) * block_width + y = content_y - (item_idx // col_count) * block_height + + item.update_placement(x, y, item_width, item_height) + item.draw(highlighted=item.hits(self.mouse_x, self.mouse_y)) + else: + font_id = 0 + text = "Communicating with Blender Cloud" + bgl.glColor4f(1.0, 1.0, 1.0, 1.0) + blf.size(font_id, 20, 72) + text_width, text_height = blf.dimensions(font_id, text) + blf.position(font_id, content_x + content_width * 0.5 - text_width * 0.5, + content_y - content_height * 0.3 + text_height * 0.5, 0) + blf.draw(font_id, text) + + bgl.glDisable(bgl.GL_BLEND) + # bgl.glColor4f(0.0, 0.0, 0.0, 1.0) + + def get_clicked(self) -> MenuItem: + + for item in self.current_display_content: + if item.hits(self.mouse_x, self.mouse_y): + return item + + return None + + def handle_item_selection(self, item: MenuItem): + """Called when the user clicks on a menu item that doesn't represent a folder.""" + pass + + +# 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 register(): + bpy.utils.register_class(BlenderCloudBrowser) + bpy.types.INFO_MT_mesh_add.append(menu_draw) + + # handle the keymap + wm = bpy.context.window_manager + kc = wm.keyconfigs.addon + if not kc: + print('No addon key configuration space found, so no custom hotkeys added.') + return + + km = kc.keymaps.new(name='3D View', space_type='VIEW_3D') + kmi = km.keymap_items.new('pillar.browser', 'A', 'PRESS', ctrl=True, shift=True, alt=True) + addon_keymaps.append((km, kmi)) + + +def unregister(): + bpy.utils.unregister_class(BlenderCloudBrowser) + + # handle the keymap + for km, kmi in addon_keymaps: + km.keymap_items.remove(kmi) + addon_keymaps.clear() + + +if __name__ == "__main__": + register() diff --git a/blender_cloud/icons/folder.png b/blender_cloud/icons/folder.png new file mode 100644 index 0000000..5e5b791 Binary files /dev/null and b/blender_cloud/icons/folder.png differ diff --git a/blender_cloud/pillar.py b/blender_cloud/pillar.py index cbdaaaa..58aa5f0 100644 --- a/blender_cloud/pillar.py +++ b/blender_cloud/pillar.py @@ -165,11 +165,11 @@ async def fetch_texture_thumbs(parent_node_uuid: str, desired_size: str, @param parent_node_uuid: the UUID of the parent node. All sub-nodes will be downloaded. @param desired_size: size indicator, from 'sbtmlh'. @param thumbnail_directory: directory in which to store the downloaded thumbnails. - @param thumbnail_loading: callback function that takes (pillarsdk.File object) - parameter, which is called before a thumbnail will be downloaded. This allows you to + @param thumbnail_loading: callback function that takes (node_id, pillarsdk.File object) + parameters, which is called before a thumbnail will be downloaded. This allows you to show a "downloading" indicator. - @param thumbnail_loaded: callback function that takes (pillarsdk.File object, thumbnail path) - parameters, which is called for every thumbnail after it's been downloaded. + @param thumbnail_loaded: callback function that takes (node_id, pillarsdk.File object, + thumbnail path) parameters, which is called for every thumbnail after it's been downloaded. """ api = pillar_api() @@ -187,7 +187,9 @@ async def fetch_texture_thumbs(parent_node_uuid: str, desired_size: str, # Find the File that belongs to this texture node pic_uuid = texture_node['picture'] file_desc = await loop.run_in_executor(None, file_find, pic_uuid) - loop.call_soon_threadsafe(functools.partial(thumbnail_loading, file_desc)) + loop.call_soon_threadsafe(functools.partial(thumbnail_loading, + texture_node['_id'], + file_desc)) if file_desc is None: print('Unable to find file for texture node {}'.format(pic_uuid)) @@ -197,7 +199,9 @@ async def fetch_texture_thumbs(parent_node_uuid: str, desired_size: str, thumb_path = await stream_thumb_to_file(file_desc, thumbnail_directory, desired_size) # print('Texture node {} has file {}'.format(texture_node['_id'], thumb_path)) - loop.call_soon_threadsafe(functools.partial(thumbnail_loaded, file_desc, thumb_path)) + loop.call_soon_threadsafe(functools.partial(thumbnail_loaded, + texture_node['_id'], + file_desc, thumb_path)) # Download all texture nodes in parallel. texture_nodes = await get_nodes(parent_node_uuid=parent_node_uuid)