From 59401d9c41e575ba29040ea6132319b8c27fa88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Mon, 14 Mar 2016 17:23:56 +0100 Subject: [PATCH] Moved GUI to 3D viewport We now draw the GUI using OpenGL in Python. This allows for much more control on the Python side. It's still a prototype, and allows us to test the features without depending on C support in Blender itself. GUI code was taken from the Asset Flinger addon. --- blender_cloud/__init__.py | 174 +------------- blender_cloud/async_loop.py | 24 +- blender_cloud/gui.py | 414 +++++++++++++++++++++++++++++++++ blender_cloud/icons/folder.png | Bin 0 -> 16506 bytes blender_cloud/pillar.py | 16 +- 5 files changed, 455 insertions(+), 173 deletions(-) create mode 100644 blender_cloud/gui.py create mode 100644 blender_cloud/icons/folder.png 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 0000000000000000000000000000000000000000..5e5b791fed3c41d1a1d4da6c0ada96e1531378ce GIT binary patch literal 16506 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 Yes8~7r4jt~-vEG|l#*nPxJmH;0VNcWGXMYp literal 0 HcmV?d00001 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)