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.
This commit is contained in:
Sybren A. Stüvel 2016-03-14 17:23:56 +01:00
parent 55eea4a9dc
commit 59401d9c41
5 changed files with 455 additions and 173 deletions

View File

@ -21,7 +21,7 @@
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, 1, 0), "version": (0, 2, 0),
"blender": (2, 77, 0), "blender": (2, 77, 0),
"location": "TO BE DETERMINED", "location": "TO BE DETERMINED",
"description": "Allows downloading of textures from the Blender Cloud. Requires " "description": "Allows downloading of textures from the Blender Cloud. Requires "
@ -32,23 +32,19 @@ bl_info = {
"support": "TESTING" "support": "TESTING"
} }
import os.path
import typing
import asyncio
# Support reloading # Support reloading
if 'pillar' in locals(): if 'pillar' in locals():
import importlib import importlib
pillar = importlib.reload(pillar) pillar = importlib.reload(pillar)
async_loop = importlib.reload(async_loop) async_loop = importlib.reload(async_loop)
gui = importlib.reload(gui)
else: else:
from . import pillar, async_loop from . import pillar, async_loop, gui
import bpy import bpy
import bpy.utils.previews from bpy.types import AddonPreferences, Operator, WindowManager
from bpy.types import AddonPreferences, Operator, PropertyGroup, WindowManager from bpy.props import StringProperty
from bpy.props import PointerProperty, StringProperty, EnumProperty
class BlenderCloudPreferences(AddonPreferences): class BlenderCloudPreferences(AddonPreferences):
@ -132,134 +128,9 @@ class PillarCredentialsUpdate(Operator):
return {'FINISHED'} 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(): def register():
bpy.utils.register_module(__name__) bpy.utils.register_class(BlenderCloudPreferences)
bpy.utils.register_class(PillarCredentialsUpdate)
WindowManager.thumbnails_cache = StringProperty( WindowManager.thumbnails_cache = StringProperty(
name="Thumbnails cache", name="Thumbnails cache",
@ -274,40 +145,19 @@ def register():
name="Blender Cloud node UUID", name="Blender Cloud node UUID",
default='') # empty == top-level of project default='') # empty == top-level of project
WindowManager.blender_cloud_thumbnails = EnumProperty( gui.register()
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
def unregister(): 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_project
del WindowManager.blender_cloud_node del WindowManager.blender_cloud_node
del WindowManager.blender_cloud_thumbnails del WindowManager.blender_cloud_thumbnails
for pcoll in preview_collections.values():
bpy.utils.previews.remove(pcoll)
preview_collections.clear()
if __name__ == "__main__": if __name__ == "__main__":
register() register()

View File

@ -1,6 +1,8 @@
"""Manages the asyncio loop.""" """Manages the asyncio loop."""
import asyncio import asyncio
import traceback
import bpy import bpy
@ -12,11 +14,27 @@ def kick_async_loop(*args):
stop_async_loop() stop_async_loop()
return 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__)) print('{}: no more scheduled tasks, stopping'.format(__name__))
stop_async_loop() stop_async_loop()
return 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 # Perform a single async loop step
async def do_nothing(): async def do_nothing():
pass pass
@ -43,7 +61,3 @@ def stop_async_loop():
if handler is None: if handler is None:
return return
bpy.app.handlers.scene_update_pre.remove(handler) bpy.app.handlers.scene_update_pre.remove(handler)
# Optional: cancel all pending tasks.
# for task in asyncio.Task.all_tasks():
# task.cancel()

414
blender_cloud/gui.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
#
# ##### 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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -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 parent_node_uuid: the UUID of the parent node. All sub-nodes will be downloaded.
@param desired_size: size indicator, from 'sbtmlh'. @param desired_size: size indicator, from 'sbtmlh'.
@param thumbnail_directory: directory in which to store the downloaded thumbnails. @param thumbnail_directory: directory in which to store the downloaded thumbnails.
@param thumbnail_loading: callback function that takes (pillarsdk.File object) @param thumbnail_loading: callback function that takes (node_id, pillarsdk.File object)
parameter, which is called before a thumbnail will be downloaded. This allows you to parameters, which is called before a thumbnail will be downloaded. This allows you to
show a "downloading" indicator. show a "downloading" indicator.
@param thumbnail_loaded: callback function that takes (pillarsdk.File object, thumbnail path) @param thumbnail_loaded: callback function that takes (node_id, pillarsdk.File object,
parameters, which is called for every thumbnail after it's been downloaded. thumbnail path) parameters, which is called for every thumbnail after it's been downloaded.
""" """
api = pillar_api() 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 # Find the File that belongs to this texture node
pic_uuid = texture_node['picture'] pic_uuid = texture_node['picture']
file_desc = await loop.run_in_executor(None, file_find, pic_uuid) 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: if file_desc is None:
print('Unable to find file for texture node {}'.format(pic_uuid)) 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) thumb_path = await stream_thumb_to_file(file_desc, thumbnail_directory, desired_size)
# print('Texture node {} has file {}'.format(texture_node['_id'], thumb_path)) # 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. # Download all texture nodes in parallel.
texture_nodes = await get_nodes(parent_node_uuid=parent_node_uuid) texture_nodes = await get_nodes(parent_node_uuid=parent_node_uuid)