Merge branch 'master' into xr-actions-D9124

This commit is contained in:
Peter Kim 2021-03-21 20:27:12 +09:00
commit e4e19a37d5
23 changed files with 1179 additions and 682 deletions

View File

@ -30,7 +30,8 @@ bl_info = {
if "bpy" in locals():
from importlib import reload
#alphabetically sorted all add-on modules since reload only happens from __init__.
# alphabetically sorted all add-on modules since reload only happens from __init__.
# modules with _bg are used for background computations in separate blender instance and that's why they don't need reload.
append_link = reload(append_link)
@ -100,7 +101,6 @@ else:
from blenderkit.bl_ui_widgets import bl_ui_draw_op
# from blenderkit.bl_ui_widgets import bl_ui_textbox
import os
import math
import time
@ -110,7 +110,6 @@ import pathlib
log = logging.getLogger(__name__)
from bpy.app.handlers import persistent
import bpy.utils.previews
import mathutils
@ -141,7 +140,6 @@ def scene_load(context):
print('loading in background')
print(bpy.context.window_manager)
if not bpy.app.background:
search.load_previews()
ui_props = bpy.context.scene.blenderkitUI
ui_props.assetbar_on = False
@ -164,7 +162,6 @@ def check_timers_timer():
return 5.0
conditions = (
('UNSPECIFIED', 'Unspecified', "Don't use this in search"),
('NEW', 'New', 'Shiny new item'),
@ -249,6 +246,7 @@ thumbnail_resolutions = (
('2048', '2048', ''),
)
def udate_down_up(self, context):
"""Perform a search if results are empty."""
s = context.scene
@ -257,6 +255,7 @@ def udate_down_up(self, context):
if wm.get('search results') == None and props.down_up == 'SEARCH':
search.search()
def switch_search_results(self, context):
s = bpy.context.scene
wm = bpy.context.window_manager
@ -279,7 +278,7 @@ def switch_search_results(self, context):
elif props.asset_type == 'BRUSH':
wm['search results'] = wm.get('bkit brush search')
wm['search results orig'] = wm.get('bkit brush search orig')
if not(context.sculpt_object or context.image_paint_object):
if not (context.sculpt_object or context.image_paint_object):
ui.add_report(
'Switch to paint or sculpt mode to search in BlenderKit brushes.')
@ -288,7 +287,6 @@ def switch_search_results(self, context):
search.search()
def asset_type_callback(self, context):
'''
Returns
@ -303,7 +301,7 @@ def asset_type_callback(self, context):
('MATERIAL', 'Materials', 'Find materials in the BlenderKit online database', 'MATERIAL', 2),
# ('TEXTURE', 'Texture', 'Browse textures', 'TEXTURE', 3),
('SCENE', 'Scenes', 'Browse scenes', 'SCENE_DATA', 3),
('HDR', 'Hdrs', 'Browse hdrs', 'WORLD', 4),
('HDR', 'HDRs', 'Browse HDRs', 'WORLD', 4),
('BRUSH', 'Brushes', 'Find brushes in the BlenderKit online database', 'BRUSH_DATA', 5)
)
else:
@ -313,7 +311,7 @@ def asset_type_callback(self, context):
('MATERIAL', 'Material', 'Upload a material to BlenderKit', 'MATERIAL', 2),
# ('TEXTURE', 'Texture', 'Browse textures', 'TEXTURE', 3),
('SCENE', 'Scenes', 'Browse scenes', 'SCENE_DATA', 3),
('HDR', 'Hdrs', 'Browse hdrs', 'WORLD', 4),
('HDR', 'HDRs', 'Browse HDRs', 'WORLD', 4),
('BRUSH', 'Brush', 'Upload a brush to BlenderKit', 'BRUSH_DATA', 5)
)
@ -330,15 +328,17 @@ class BlenderKitUIProps(PropertyGroup):
),
description="BLenderKit",
default="SEARCH",
update = udate_down_up
update=udate_down_up
)
asset_type: EnumProperty(
name="BlenderKit Active Asset Type",
items=asset_type_callback,
description="Activate asset in UI",
description="",
default=None,
update=switch_search_results
)
asset_type_expand: BoolProperty(name="Expand asset types", default=False)
# these aren't actually used ( by now, seems to better use globals in UI module:
draw_tooltip: BoolProperty(name="Draw Tooltip", default=False)
addon_update: BoolProperty(name="Should Update Addon", default=False)
@ -533,7 +533,7 @@ class BlenderKitCommonSearchProps(object):
('UPLOADING', 'Uploading', 'Uploading'),
('UPLOADED', 'Uploaded', 'Uploaded'),
('READY', 'Ready for V.', 'Ready for validation (deprecated since 2.8)'),
('VALIDATED', 'Validated', 'Calidated'),
('VALIDATED', 'Validated', 'Validated'),
('ON_HOLD', 'On Hold', 'On Hold'),
('REJECTED', 'Rejected', 'Rejected'),
('DELETED', 'Deleted', 'Deleted'),
@ -577,16 +577,14 @@ def name_update(self, context):
utils.name_update(self)
def update_free(self, context):
if self.is_free == False:
self.is_free = True
ui_panels.ui_message(title = "All BlenderKit materials are free",
message = "Any material uploaded to BlenderKit is free." \
" However, it can still earn money for the author," \
" based on our fair share system. " \
"Part of subscription is sent to artists based on usage by paying users.")
if self.is_free == 'FULL':
self.is_free = 'FREE'
ui_panels.ui_message(title="All BlenderKit materials are free",
message="Any material uploaded to BlenderKit is free." \
" However, it can still earn money for the author," \
" based on our fair share system. " \
"Part of subscription is sent to artists based on usage by paying users.\n")
class BlenderKitCommonUploadProps(object):
@ -667,9 +665,15 @@ class BlenderKitCommonUploadProps(object):
# "Private assets are limited by quota",
# default=False)
is_free: BoolProperty(name="Free for Everyone",
description="You consent you want to release this asset as free for everyone",
default=False)
is_free: EnumProperty(
name="Thumbnail Style",
items=(
('FREE', 'Free', "You consent you want to release this asset as free for everyone."),
('FULL', 'Full', "Your asset will be only available for subscribers")
),
description="Assets can be in Free or in Full plan. Also free assets generate credits.",
default="FULL",
)
uploading: BoolProperty(name="Uploading",
description="True when background process is running",
@ -686,7 +690,7 @@ class BlenderKitCommonUploadProps(object):
thumbnail_generating_state: StringProperty(
name="Thumbnail Generating State",
description="bg process reports for thumbnail generation",
default='Please add thumbnail(jpg, at least 512x512)')
default='Please add thumbnail(jpg or png, at least 512x512)')
report: StringProperty(
name="Missing Upload Properties",
@ -832,10 +836,19 @@ class BlenderKitMaterialUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
default="",
)
is_free: BoolProperty(name="Free for Everyone",
description="You consent you want to release this asset as free for everyone",
default=True, update=update_free
)
is_free: EnumProperty(
name="Thumbnail Style",
items=(
('FREE', 'Free', "You consent you want to release this asset as free for everyone."),
('FULL', 'Full', "Your asset will be only available for subscribers.")
),
description="Assets can be in Free or in Full plan. Also free assets generate credits. \n"
"All BlenderKit materials are free.",
default="FREE",
update=update_free
)
uv: BoolProperty(name="Needs UV", description="needs an UV set", default=False)
# printable_3d : BoolProperty( name = "3d printable", description = "can be 3d printed", default = False)
@ -889,7 +902,9 @@ class BlenderKitMaterialUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
thumbnail: StringProperty(
name="Thumbnail",
description="Path to the thumbnail - 512x512 .jpg image",
description="Thumbnail path - 512x512 .jpg image, rendered with cycles. \n"
"Only standard BlenderKit previews will be accepted.\n"
"Only exception are special effects like fire or similar.",
subtype='FILE_PATH',
default="",
update=autothumb.update_upload_material_preview)
@ -932,6 +947,7 @@ class BlenderKitHDRUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
texture_resolution_max: IntProperty(name="Texture Resolution Max", description="texture resolution maximum",
default=0)
class BlenderKitBrushUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
mode: EnumProperty(
name="Mode",
@ -1005,7 +1021,7 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
manufacturer: StringProperty(
name="Manufacturer",
description="Manufacturer, company making a design peace or product. Not you",
description="Manufacturer, company making a design piece or product. Not you",
default="",
)
@ -1029,7 +1045,9 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
thumbnail: StringProperty(
name="Thumbnail",
description="Path to the thumbnail - 512x512 .jpg image",
description="Thumbnail path - 512x512 .jpg\n"
"Rendered with cycles",
subtype='FILE_PATH',
default="",
update=autothumb.update_upload_model_preview)
@ -1215,7 +1233,8 @@ class BlenderKitSceneUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
thumbnail: StringProperty(
name="Thumbnail",
description="Path to the thumbnail - 512x512 .jpg image",
description="Thumbnail path - 512x512 .jpg\n"
"Rendered with cycles",
subtype='FILE_PATH',
default="",
update=autothumb.update_upload_scene_preview)
@ -1336,7 +1355,6 @@ class BlenderKitModelSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
update=search.search_update
)
# CONDITION
search_condition: EnumProperty(
items=conditions,
@ -1508,10 +1526,11 @@ class BlenderKitSceneSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
default="APPEND"
)
switch_after_append: BoolProperty(
name = 'Switch to scene after download',
default = False
name='Switch to scene after download',
default=False
)
def fix_subdir(self, context):
'''Fixes project subdicrectory settings if people input invalid path.'''
@ -1524,11 +1543,10 @@ def fix_subdir(self, context):
if self.project_subdir != pp:
self.project_subdir = pp
ui_panels.ui_message(title = "Fixed to relative path",
message = "This path should be always realative.\n" \
" It's a directory BlenderKit creates where your .blend is \n " \
"and uses it for storing assets.")
ui_panels.ui_message(title="Fixed to relative path",
message="This path should be always realative.\n" \
" It's a directory BlenderKit creates where your .blend is \n " \
"and uses it for storing assets.")
class BlenderKitAddonPreferences(AddonPreferences):
@ -1687,6 +1705,14 @@ class BlenderKitAddonPreferences(AddonPreferences):
default=False,
update=utils.save_prefs
)
categories_fix: BoolProperty(
name="Enable category fixing mode",
description="Enable category fixing mode.",
default=False,
update=utils.save_prefs
)
# allow_proximity : BoolProperty(
# name="allow proximity data reports",
# description="This sends anonymized proximity data \n \
@ -1728,6 +1754,7 @@ class BlenderKitAddonPreferences(AddonPreferences):
if bpy.context.preferences.view.show_developer_ui:
layout.prop(self, "use_timers")
layout.prop(self, "experimental_features")
layout.prop(self, "categories_fix")
# registration

View File

@ -1267,6 +1267,7 @@ class BlenderkitDownloadOperator(bpy.types.Operator):
description='Replace resolution'
)
#needs to be passed to the operator to not show all resolution possibilities
max_resolution: IntProperty(
name="Max resolution",
description="",
@ -1382,13 +1383,14 @@ class BlenderkitDownloadOperator(bpy.types.Operator):
# only make a pop up in case of switching resolutions
if self.invoke_resolution:
# show_enum_values(self, 'resolution')
# print('ENUM VALUES')
self.asset_data = self.get_asset_data(context)
sprops = utils.get_search_props()
if int(sprops.resolution) <= int(self.max_resolution):
#set initial resolutions enum activation
if sprops.resolution != 'ORIGINAL' and int(sprops.resolution) <= int(self.max_resolution):
self.resolution = sprops.resolution
elif int(self.max_resolution) > 0:
self.resolution = self.max_resolution
self.resolution = str(self.max_resolution)
else:
self.resolution = 'ORIGINAL'
return wm.invoke_props_dialog(self)

View File

@ -1,6 +1,6 @@
import bpy
import numpy
import os
import time
def get_orig_render_settings():
rs = bpy.context.scene.render
@ -67,6 +67,7 @@ def set_colorspace(img, colorspace):
print(f'Colorspace {colorspace} not found.')
def generate_hdr_thumbnail():
import numpy
scene = bpy.context.scene
ui_props = scene.blenderkitUI
hdr_image = ui_props.hdr_upload_image#bpy.data.images.get(ui_props.hdr_upload_image)
@ -98,3 +99,391 @@ def generate_hdr_thumbnail():
inew.scale(thumbnailWidth, thumbnailHeight)
img_save_as(inew, filepath=inew.filepath)
def find_color_mode(image):
if not isinstance(image, bpy.types.Image):
raise(TypeError)
else:
depth_mapping = {
8: 'BW',
24: 'RGB',
32: 'RGBA',#can also be bw.. but image.channels doesn't work.
96: 'RGB',
128: 'RGBA',
}
return depth_mapping.get(image.depth,'RGB')
def find_image_depth(image):
if not isinstance(image, bpy.types.Image):
raise(TypeError)
else:
depth_mapping = {
8: '8',
24: '8',
32: '8',#can also be bw.. but image.channels doesn't work.
96: '16',
128: '16',
}
return depth_mapping.get(image.depth,'8')
def can_erase_alpha(na):
alpha = na[3::4]
alpha_sum = alpha.sum()
if alpha_sum == alpha.size:
print('image can have alpha erased')
# print(alpha_sum, alpha.size)
return alpha_sum == alpha.size
def is_image_black(na):
r = na[::4]
g = na[1::4]
b = na[2::4]
rgbsum = r.sum() + g.sum() + b.sum()
# print('rgb sum', rgbsum, r.sum(), g.sum(), b.sum())
if rgbsum == 0:
print('image can have alpha channel dropped')
return rgbsum == 0
def is_image_bw(na):
r = na[::4]
g = na[1::4]
b = na[2::4]
rg_equal = r == g
gb_equal = g == b
rgbequal = rg_equal.all() and gb_equal.all()
if rgbequal:
print('image is black and white, can have channels reduced')
return rgbequal
def numpytoimage(a, iname, width=0, height=0, channels=3):
t = time.time()
foundimage = False
for image in bpy.data.images:
if image.name[:len(iname)] == iname and image.size[0] == a.shape[0] and image.size[1] == a.shape[1]:
i = image
foundimage = True
if not foundimage:
if channels == 4:
bpy.ops.image.new(name=iname, width=width, height=height, color=(0, 0, 0, 1), alpha=True,
generated_type='BLANK', float=True)
if channels == 3:
bpy.ops.image.new(name=iname, width=width, height=height, color=(0, 0, 0), alpha=False,
generated_type='BLANK', float=True)
i = None
for image in bpy.data.images:
# print(image.name[:len(iname)],iname, image.size[0],a.shape[0],image.size[1],a.shape[1])
if image.name[:len(iname)] == iname and image.size[0] == width and image.size[1] == height:
i = image
if i is None:
i = bpy.data.images.new(iname, width, height, alpha=False, float_buffer=False, stereo3d=False, is_data=False, tiled=False)
# dropping this re-shaping code - just doing flat array for speed and simplicity
# d = a.shape[0] * a.shape[1]
# a = a.swapaxes(0, 1)
# a = a.reshape(d)
# a = a.repeat(channels)
# a[3::4] = 1
i.pixels.foreach_set(a) # this gives big speedup!
print('\ntime ' + str(time.time() - t))
return i
def imagetonumpy_flat(i):
t = time.time()
import numpy
width = i.size[0]
height = i.size[1]
# print(i.channels)
size = width * height * i.channels
na = numpy.empty(size, numpy.float32)
i.pixels.foreach_get(na)
# dropping this re-shaping code - just doing flat array for speed and simplicity
# na = na[::4]
# na = na.reshape(height, width, i.channels)
# na = na.swapaxnes(0, 1)
# print('\ntime of image to numpy ' + str(time.time() - t))
return na
def imagetonumpy(i):
t = time.time()
import numpy as np
width = i.size[0]
height = i.size[1]
# print(i.channels)
size = width * height * i.channels
na = np.empty(size, np.float32)
i.pixels.foreach_get(na)
# dropping this re-shaping code - just doing flat array for speed and simplicity
# na = na[::4]
na = na.reshape(height, width, i.channels)
na = na.swapaxes(0, 1)
# print('\ntime of image to numpy ' + str(time.time() - t))
return na
def downscale(i):
minsize = 128
sx, sy = i.size[:]
sx = round(sx / 2)
sy = round(sy / 2)
if sx > minsize and sy > minsize:
i.scale(sx, sy)
def get_rgb_mean(i):
'''checks if normal map values are ok.'''
import numpy
na = imagetonumpy_flat(i)
r = na[::4]
g = na[1::4]
b = na[2::4]
rmean = r.mean()
gmean = g.mean()
bmean = b.mean()
rmedian = numpy.median(r)
gmedian = numpy.median(g)
bmedian = numpy.median(b)
# return(rmedian,gmedian, bmedian)
return (rmean, gmean, bmean)
def check_nmap_mean_ok(i):
'''checks if normal map values are in standard range.'''
rmean,gmean,bmean = get_rgb_mean(i)
#we could/should also check blue, but some ogl substance exports have 0-1, while 90% nmaps have 0.5 - 1.
nmap_ok = 0.45< rmean < 0.55 and .45 < gmean < .55
return nmap_ok
def check_nmap_ogl_vs_dx(i, mask = None, generated_test_images = False):
'''
checks if normal map is directX or OpenGL.
Returns - String value - DirectX and OpenGL
'''
import numpy
width = i.size[0]
height = i.size[1]
rmean, gmean, bmean = get_rgb_mean(i)
na = imagetonumpy(i)
if mask:
mask = imagetonumpy(mask)
red_x_comparison = numpy.zeros((width, height), numpy.float32)
green_y_comparison = numpy.zeros((width, height), numpy.float32)
if generated_test_images:
red_x_comparison_img = numpy.empty((width, height, 4), numpy.float32) #images for debugging purposes
green_y_comparison_img = numpy.empty((width, height, 4), numpy.float32)#images for debugging purposes
ogl = numpy.zeros((width, height), numpy.float32)
dx = numpy.zeros((width, height), numpy.float32)
if generated_test_images:
ogl_img = numpy.empty((width, height, 4), numpy.float32) # images for debugging purposes
dx_img = numpy.empty((width, height, 4), numpy.float32) # images for debugging purposes
for y in range(0, height):
for x in range(0, width):
#try to mask with UV mask image
if mask is None or mask[x,y,3]>0:
last_height_x = ogl[max(x - 1, 0), min(y, height - 1)]
last_height_y = ogl[max(x,0), min(y - 1,height-1)]
diff_x = ((na[x, y, 0] - rmean) / ((na[x, y, 2] - 0.5)))
diff_y = ((na[x, y, 1] - gmean) / ((na[x, y, 2] - 0.5)))
calc_height = (last_height_x + last_height_y) \
- diff_x - diff_y
calc_height = calc_height /2
ogl[x, y] = calc_height
if generated_test_images:
rgb = calc_height *.1 +.5
ogl_img[x,y] = [rgb,rgb,rgb,1]
# green channel
last_height_x = dx[max(x - 1, 0), min(y, height - 1)]
last_height_y = dx[max(x, 0), min(y - 1, height - 1)]
diff_x = ((na[x, y, 0] - rmean) / ((na[x, y, 2] - 0.5)))
diff_y = ((na[x, y, 1] - gmean) / ((na[x, y, 2] - 0.5)))
calc_height = (last_height_x + last_height_y) \
- diff_x + diff_y
calc_height = calc_height / 2
dx[x, y] = calc_height
if generated_test_images:
rgb = calc_height * .1 + .5
dx_img[x, y] = [rgb, rgb, rgb, 1]
ogl_std = ogl.std()
dx_std = dx.std()
# print(mean_ogl, mean_dx)
# print(max_ogl, max_dx)
print(ogl_std, dx_std)
print(i.name)
# if abs(mean_ogl) > abs(mean_dx):
if abs(ogl_std) > abs(dx_std):
print('this is probably a DirectX texture')
else:
print('this is probably an OpenGL texture')
if generated_test_images:
# red_x_comparison_img = red_x_comparison_img.swapaxes(0,1)
# red_x_comparison_img = red_x_comparison_img.flatten()
#
# green_y_comparison_img = green_y_comparison_img.swapaxes(0,1)
# green_y_comparison_img = green_y_comparison_img.flatten()
#
# numpytoimage(red_x_comparison_img, 'red_' + i.name, width=width, height=height, channels=1)
# numpytoimage(green_y_comparison_img, 'green_' + i.name, width=width, height=height, channels=1)
ogl_img = ogl_img.swapaxes(0, 1)
ogl_img = ogl_img.flatten()
dx_img = dx_img.swapaxes(0, 1)
dx_img = dx_img.flatten()
numpytoimage(ogl_img, 'OpenGL', width=width, height=height, channels=1)
numpytoimage(dx_img, 'DirectX', width=width, height=height, channels=1)
if abs(ogl_std) > abs(dx_std):
return 'DirectX'
return 'OpenGL'
def make_possible_reductions_on_image(teximage, input_filepath, do_reductions=False, do_downscale=False):
'''checks the image and saves it to drive with possibly reduced channels.
Also can remove the image from the asset if the image is pure black
- it finds it's usages and replaces the inputs where the image is used
with zero/black color.
currently implemented file type conversions:
PNG->JPG
'''
colorspace = teximage.colorspace_settings.name
teximage.colorspace_settings.name = 'Non-Color'
#teximage.colorspace_settings.name = 'sRGB' color correction mambo jambo.
JPEG_QUALITY = 90
# is_image_black(na)
# is_image_bw(na)
rs = bpy.context.scene.render
ims = rs.image_settings
orig_file_format = ims.file_format
orig_quality = ims.quality
orig_color_mode = ims.color_mode
orig_compression = ims.compression
orig_depth = ims.color_depth
# if is_image_black(na):
# # just erase the image from the asset here, no need to store black images.
# pass;
# fp = teximage.filepath
# setup image depth, 8 or 16 bit.
# this should normally divide depth with number of channels, but blender always states that number of channels is 4, even if there are only 3
print(teximage.name)
print(teximage.depth)
print(teximage.channels)
bpy.context.scene.display_settings.display_device = 'None'
image_depth = find_image_depth(teximage)
ims.color_mode = find_color_mode(teximage)
#image_depth = str(max(min(int(teximage.depth / 3), 16), 8))
print('resulting depth set to:', image_depth)
fp = input_filepath
if do_reductions:
na = imagetonumpy_flat(teximage)
if can_erase_alpha(na):
print(teximage.file_format)
if teximage.file_format == 'PNG':
print('changing type of image to JPG')
base, ext = os.path.splitext(fp)
teximage['original_extension'] = ext
fp = fp.replace('.png', '.jpg')
fp = fp.replace('.PNG', '.jpg')
teximage.name = teximage.name.replace('.png', '.jpg')
teximage.name = teximage.name.replace('.PNG', '.jpg')
teximage.file_format = 'JPEG'
ims.quality = JPEG_QUALITY
ims.color_mode = 'RGB'
if is_image_bw(na):
ims.color_mode = 'BW'
ims.file_format = teximage.file_format
ims.color_depth = image_depth
# all pngs with max compression
if ims.file_format == 'PNG':
ims.compression = 100
# all jpgs brought to reasonable quality
if ims.file_format == 'JPG':
ims.quality = JPEG_QUALITY
if do_downscale:
downscale(teximage)
# it's actually very important not to try to change the image filepath and packed file filepath before saving,
# blender tries to re-pack the image after writing to image.packed_image.filepath and reverts any changes.
teximage.save_render(filepath=bpy.path.abspath(fp), scene=bpy.context.scene)
if len(teximage.packed_files) > 0:
teximage.unpack(method='REMOVE')
teximage.filepath = fp
teximage.filepath_raw = fp
teximage.reload()
teximage.colorspace_settings.name = colorspace
ims.file_format = orig_file_format
ims.quality = orig_quality
ims.color_mode = orig_color_mode
ims.compression = orig_compression
ims.color_depth = orig_depth

View File

@ -169,7 +169,7 @@ def slugify(slug):
import unicodedata, re
slug = slug.lower()
characters = '.," <>()'
characters = '<>:"/\\|?*., ()'
for ch in characters:
slug = slug.replace(ch, '_')
# import re
@ -179,6 +179,8 @@ def slugify(slug):
slug = re.sub(r'[-]+', '-', slug)
slug = re.sub(r'/', '_', slug)
slug = re.sub(r'\\\'\"', '_', slug)
if len(slug)>50:
slug = slug[:50]
return slug

View File

@ -295,7 +295,7 @@ def update_ratings_work_hours_ui_1_5(self, context):
class FastRateMenu(Operator):
"""Fast rating of the assets directly in the asset bar - without need to download assets"""
"""Rating of the assets , also directly from the asset bar - without need to download assets"""
bl_idname = "wm.blenderkit_menu_rating_upload"
bl_label = "Rate asset"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}

View File

@ -22,7 +22,6 @@ from blenderkit import paths, append_link, bg_blender, utils, download, search,
import sys, json, os, time
import subprocess
import tempfile
import numpy as np
import bpy
import requests
import math
@ -56,98 +55,9 @@ def get_current_resolution():
return actres
def can_erase_alpha(na):
alpha = na[3::4]
alpha_sum = alpha.sum()
if alpha_sum == alpha.size:
print('image can have alpha erased')
# print(alpha_sum, alpha.size)
return alpha_sum == alpha.size
def is_image_black(na):
r = na[::4]
g = na[1::4]
b = na[2::4]
rgbsum = r.sum() + g.sum() + b.sum()
# print('rgb sum', rgbsum, r.sum(), g.sum(), b.sum())
if rgbsum == 0:
print('image can have alpha channel dropped')
return rgbsum == 0
def is_image_bw(na):
r = na[::4]
g = na[1::4]
b = na[2::4]
rg_equal = r == g
gb_equal = g == b
rgbequal = rg_equal.all() and gb_equal.all()
if rgbequal:
print('image is black and white, can have channels reduced')
return rgbequal
def numpytoimage(a, iname, width=0, height=0, channels=3):
t = time.time()
foundimage = False
for image in bpy.data.images:
if image.name[:len(iname)] == iname and image.size[0] == a.shape[0] and image.size[1] == a.shape[1]:
i = image
foundimage = True
if not foundimage:
if channels == 4:
bpy.ops.image.new(name=iname, width=width, height=height, color=(0, 0, 0, 1), alpha=True,
generated_type='BLANK', float=True)
if channels == 3:
bpy.ops.image.new(name=iname, width=width, height=height, color=(0, 0, 0), alpha=False,
generated_type='BLANK', float=True)
for image in bpy.data.images:
# print(image.name[:len(iname)],iname, image.size[0],a.shape[0],image.size[1],a.shape[1])
if image.name[:len(iname)] == iname and image.size[0] == width and image.size[1] == height:
i = image
# dropping this re-shaping code - just doing flat array for speed and simplicity
# d = a.shape[0] * a.shape[1]
# a = a.swapaxes(0, 1)
# a = a.reshape(d)
# a = a.repeat(channels)
# a[3::4] = 1
i.pixels.foreach_set(a) # this gives big speedup!
print('\ntime ' + str(time.time() - t))
return i
def imagetonumpy(i):
t = time.time()
width = i.size[0]
height = i.size[1]
# print(i.channels)
size = width * height * i.channels
na = np.empty(size, np.float32)
i.pixels.foreach_get(na)
# dropping this re-shaping code - just doing flat array for speed and simplicity
# na = na[::4]
# na = na.reshape(height, width, i.channels)
# na = na.swapaxnes(0, 1)
# print('\ntime of image to numpy ' + str(time.time() - t))
return na
def save_image_safely(teximage, filepath):
'''
Blender makes it really hard to save images... this is to fix it's crazy bad image saving.
Blender makes it really hard to save images...
Would be worth investigating PIL or similar instead
Parameters
----------
@ -203,95 +113,8 @@ def extxchange_to_resolution(filepath):
ext = 'jpg'
def make_possible_reductions_on_image(teximage, input_filepath, do_reductions=False, do_downscale=False):
'''checks the image and saves it to drive with possibly reduced channels.
Also can remove the image from the asset if the image is pure black
- it finds it's usages and replaces the inputs where the image is used
with zero/black color.
currently implemented file type conversions:
PNG->JPG
'''
colorspace = teximage.colorspace_settings.name
teximage.colorspace_settings.name = 'Non-Color'
JPEG_QUALITY = 90
# is_image_black(na)
# is_image_bw(na)
rs = bpy.context.scene.render
ims = rs.image_settings
orig_file_format = ims.file_format
orig_quality = ims.quality
orig_color_mode = ims.color_mode
orig_compression = ims.compression
# if is_image_black(na):
# # just erase the image from the asset here, no need to store black images.
# pass;
# fp = teximage.filepath
fp = input_filepath
if do_reductions:
na = imagetonumpy(teximage)
if can_erase_alpha(na):
print(teximage.file_format)
if teximage.file_format == 'PNG':
print('changing type of image to JPG')
base, ext = os.path.splitext(fp)
teximage['original_extension'] = ext
fp = fp.replace('.png', '.jpg')
fp = fp.replace('.PNG', '.jpg')
teximage.name = teximage.name.replace('.png', '.jpg')
teximage.name = teximage.name.replace('.PNG', '.jpg')
teximage.file_format = 'JPEG'
ims.quality = JPEG_QUALITY
ims.color_mode = 'RGB'
if is_image_bw(na):
ims.color_mode = 'BW'
ims.file_format = teximage.file_format
# all pngs with max compression
if ims.file_format == 'PNG':
ims.compression = 100
# all jpgs brought to reasonable quality
if ims.file_format == 'JPG':
ims.quality = JPEG_QUALITY
if do_downscale:
downscale(teximage)
# it's actually very important not to try to change the image filepath and packed file filepath before saving,
# blender tries to re-pack the image after writing to image.packed_image.filepath and reverts any changes.
teximage.save_render(filepath=bpy.path.abspath(fp), scene=bpy.context.scene)
if len(teximage.packed_files) > 0:
teximage.unpack(method='REMOVE')
teximage.filepath = fp
teximage.filepath_raw = fp
teximage.reload()
teximage.colorspace_settings.name = colorspace
ims.file_format = orig_file_format
ims.quality = orig_quality
ims.color_mode = orig_color_mode
ims.compression = orig_compression
def downscale(i):
minsize = 128
sx, sy = i.size[:]
sx = round(sx / 2)
sy = round(sy / 2)
if sx > minsize and sy > minsize:
i.scale(sx, sy)
def upload_resolutions(files, asset_data):
@ -340,9 +163,10 @@ def unpack_asset(data):
pf.filepath = fp # bpy.path.abspath(fp)
image.filepath = fp # bpy.path.abspath(fp)
image.filepath_raw = fp # bpy.path.abspath(fp)
image.save()
# image.save()
if len(image.packed_files) > 0:
image.unpack(method='REMOVE')
# image.unpack(method='REMOVE')
image.unpack(method='WRITE_ORIGINAL')
bpy.ops.wm.save_mainfile(compress=False)
# now try to delete the .blend1 file
@ -523,11 +347,11 @@ def generate_lower_resolutions(data):
# first, let's link the image back to the original one.
i['blenderkit_original_path'] = i.filepath
# first round also makes reductions on the image, while keeping resolution
make_possible_reductions_on_image(i, fp, do_reductions=True, do_downscale=False)
image_utils.make_possible_reductions_on_image(i, fp, do_reductions=True, do_downscale=False)
else:
# lower resolutions only downscale
make_possible_reductions_on_image(i, fp, do_reductions=False, do_downscale=True)
image_utils.make_possible_reductions_on_image(i, fp, do_reductions=False, do_downscale=True)
abspath = bpy.path.abspath(i.filepath)
if os.path.exists(abspath):
@ -555,7 +379,7 @@ def generate_lower_resolutions(data):
else:
p2res = rkeys[rkeys.index(p2res) - 1]
print('uploading resolution files')
upload_resolutions(files, data['asset_data'])
#upload_resolutions(files, data['asset_data'])
preferences = bpy.context.preferences.addons['blenderkit'].preferences
patch_asset_empty(data['asset_data']['id'], preferences.api_key)
return
@ -665,41 +489,6 @@ def get_materials_for_validation(page_size=100, max_results=100000000):
return filepath
# This gets all assets in the database through the/assets endpoint. Currently not used, since we use elastic for everything.
# def get_assets_list():
# bpy.app.debug_value = 2
#
# results = []
# preferences = bpy.context.preferences.addons['blenderkit'].preferences
# url = paths.get_api_url() + 'assets/all'
# i = 0
# while url is not None:
# headers = utils.get_headers(preferences.api_key)
# print('fetching assets from assets endpoint')
# print(url)
# retries = 0
# while retries < 3:
# r = rerequests.get(url, headers=headers)
#
# try:
# adata = r.json()
# url = adata.get('next')
# print(i)
# i += 1
# except Exception as e:
# print(e)
# print('failed to get next')
# if retries == 2:
# url = None
# if adata.get('results') != None:
# results.extend(adata['results'])
# retries = 3
# print(f'fetched page {i}')
# retries += 1
#
# fpath = assets_db_path()
# with open(fpath, 'w', encoding = 'utf-8') as s:
# json.dump(results, s, ensure_ascii=False, indent=4)
def load_assets_list(filepath):
@ -757,6 +546,7 @@ def generate_resolution_thread(asset_data, api_key):
'''
fpath = download_asset(asset_data, unpack=True, api_key=api_key)
if fpath:
if asset_data['assetType'] != 'hdr':
print('send to bg ', fpath)

View File

@ -238,6 +238,8 @@ def parse_result(r):
# utils.p('asset with no files-size')
asset_type = r['assetType']
if len(r['files']) > 0:#TODO remove this condition so all assets are parsed.
get_author(r)
r['available_resolutions'] = []
allthumbs = []
durl, tname, small_tname = '', '', ''
@ -553,7 +555,7 @@ def writeblockm(tooltip, mdata, key='', pretext=None, width=40): # for longer t
def fmt_length(prop):
prop = str(round(prop, 2)) + 'm'
prop = str(round(prop, 2))
return prop
@ -590,9 +592,9 @@ def generate_tooltip(mdata):
for b in bools_data:
if mdata.get(b) and mdata[b]:
mdata['tags'].append(b)
t = writeblockm(t, mparams, key='designer', pretext='designer', width=col_w)
t = writeblockm(t, mparams, key='manufacturer', pretext='manufacturer', width=col_w)
t = writeblockm(t, mparams, key='designCollection', pretext='design collection', width=col_w)
t = writeblockm(t, mparams, key='designer', pretext='Designer', width=col_w)
t = writeblockm(t, mparams, key='manufacturer', pretext='Manufacturer', width=col_w)
t = writeblockm(t, mparams, key='designCollection', pretext='Design collection', width=col_w)
# t = writeblockm(t, mparams, key='engines', pretext='engine', width = col_w)
# t = writeblockm(t, mparams, key='model_style', pretext='style', width = col_w)
@ -601,21 +603,22 @@ def generate_tooltip(mdata):
# t = writeblockm(t, mparams, key='condition', pretext='condition', width = col_w)
# t = writeblockm(t, mparams, key='productionLevel', pretext='production level', width = col_w)
if has(mdata, 'purePbr'):
t = writeblockm(t, mparams, key='pbrType', pretext='pbr', width=col_w)
t = writeblockm(t, mparams, key='pbrType', pretext='Pbr', width=col_w)
t = writeblockm(t, mparams, key='designYear', pretext='design year', width=col_w)
t = writeblockm(t, mparams, key='designYear', pretext='Design year', width=col_w)
if has(mparams, 'dimensionX'):
t += 'size: %s, %s, %s\n' % (fmt_length(mparams['dimensionX']),
t += 'Size: %s x %s x %sm\n' % (fmt_length(mparams['dimensionX']),
fmt_length(mparams['dimensionY']),
fmt_length(mparams['dimensionZ']))
if has(mparams, 'faceCount'):
t += 'face count: %s, render: %s\n' % (mparams['faceCount'], mparams['faceCountRender'])
t += 'Face count: %s\n' % (mparams['faceCount'])
# t += 'face count: %s, render: %s\n' % (mparams['faceCount'], mparams['faceCountRender'])
# write files size - this doesn't reflect true file size, since files size is computed from all asset files, including resolutions.
if mdata.get('filesSize'):
fs = utils.files_size_to_text(mdata['filesSize'])
t += f'files size: {fs}\n'
# if mdata.get('filesSize'):
# fs = utils.files_size_to_text(mdata['filesSize'])
# t += f'files size: {fs}\n'
# t = writeblockm(t, mparams, key='meshPolyType', pretext='mesh type', width = col_w)
# t = writeblockm(t, mparams, key='objectCount', pretext='nubmber of objects', width = col_w)
@ -624,36 +627,44 @@ def generate_tooltip(mdata):
# t = writeblockm(t, mparams, key='modifiers', width = col_w)
# t = writeblockm(t, mparams, key='shaders', width = col_w)
if has(mparams, 'textureSizeMeters'):
t += 'texture size: %s\n' % fmt_length(mparams['textureSizeMeters'])
# if has(mparams, 'textureSizeMeters'):
# t += 'Texture size: %s m\n' % fmt_length(mparams['textureSizeMeters'])
if has(mparams, 'textureResolutionMax') and mparams['textureResolutionMax'] > 0:
if not mparams.get('textureResolutionMin'): # for HDR's
t = writeblockm(t, mparams, key='textureResolutionMax', pretext='Resolution', width=col_w)
elif mparams.get('textureResolutionMin') == mparams['textureResolutionMax']:
t = writeblockm(t, mparams, key='textureResolutionMin', pretext='texture resolution', width=col_w)
t = writeblockm(t, mparams, key='textureResolutionMin', pretext='Texture resolution', width=col_w)
else:
t += 'tex resolution: %i - %i\n' % (mparams.get('textureResolutionMin'), mparams['textureResolutionMax'])
t += 'Tex resolution: %i - %i\n' % (mparams.get('textureResolutionMin'), mparams['textureResolutionMax'])
if has(mparams, 'thumbnailScale'):
t = writeblockm(t, mparams, key='thumbnailScale', pretext='preview scale', width=col_w)
t = writeblockm(t, mparams, key='thumbnailScale', pretext='Preview scale', width=col_w)
# t += 'uv: %s\n' % mdata['uv']
# t += '\n'
t = writeblockm(t, mdata, key='license', width=col_w)
if mdata.get('license') == 'cc_zero':
t+= 'license: CC Zero\n'
else:
t+= 'license: Royalty free\n'
# t = writeblockm(t, mdata, key='license', width=col_w)
fs = mdata.get('files')
if utils.profile_is_validator():
if fs:
resolutions = 'resolutions:'
if fs and len(fs) > 2:
resolutions = 'Resolutions:'
list.sort(fs, key=lambda f: f['fileType'])
for f in fs:
if f['fileType'].find('resolution') > -1:
resolutions += f['fileType'][11:] + ' '
resolutions += '\n'
t += resolutions
t += resolutions.replace('_', '.')
t = writeblockm(t, mdata, key='isFree', width=col_w)
if mdata['isFree']:
t += 'Free plan\n'
else:
t += 'Full plan\n'
else:
if fs:
for f in fs:
@ -670,7 +681,7 @@ def generate_tooltip(mdata):
# if adata != None:
# t += generate_author_textblock(adata)
# t += '\n'
t += '\n'
rc = mdata.get('ratingsCount')
if rc:
t+='\n'
@ -890,16 +901,62 @@ def get_profile():
thread.start()
return a
def query_to_url(query = {}, params = {}):
# build a new request
url = paths.get_api_url() + 'search/'
# build request manually
# TODO use real queries
requeststring = '?query='
#
if query.get('query') not in ('', None):
requeststring += query['query'].lower()
for i, q in enumerate(query):
if q != 'query':
requeststring += '+'
requeststring += q + ':' + str(query[q]).lower()
# result ordering: _score - relevance, score - BlenderKit score
order = []
if params['free_first']:
order = ['-is_free', ]
if query.get('query') is None and query.get('category_subtree') == None:
# assumes no keywords and no category, thus an empty search that is triggered on start.
# orders by last core file upload
if query.get('verification_status') == 'uploaded':
# for validators, sort uploaded from oldest
order.append('created')
else:
order.append('-last_upload')
elif query.get('author_id') is not None and utils.profile_is_validator():
order.append('-created')
else:
if query.get('category_subtree') is not None:
order.append('-score,_score')
else:
order.append('_score')
requeststring += '+order:' + ','.join(order)
requeststring += '&addon_version=%s' % params['addon_version']
if params.get('scene_uuid') is not None:
requeststring += '&scene_uuid=%s' % params['scene_uuid']
# print('params', params)
urlquery = url + requeststring
return urlquery
class Searcher(threading.Thread):
query = None
def __init__(self, query, params, orig_result):
def __init__(self, query, params, orig_result, tempdir = '', headers = None, urlquery = ''):
super(Searcher, self).__init__()
self.query = query
self.params = params
self._stop_event = threading.Event()
self.result = orig_result
self.tempdir = tempdir
self.headers = headers
self.urlquery = urlquery
def stop(self):
self._stop_event.set()
@ -907,52 +964,6 @@ class Searcher(threading.Thread):
def stopped(self):
return self._stop_event.is_set()
def query_to_url(self):
query = self.query
params = self.params
# build a new request
url = paths.get_api_url() + 'search/'
# build request manually
# TODO use real queries
requeststring = '?query='
#
if query.get('query') not in ('', None):
requeststring += query['query'].lower()
for i, q in enumerate(query):
if q != 'query':
requeststring += '+'
requeststring += q + ':' + str(query[q]).lower()
# result ordering: _score - relevance, score - BlenderKit score
order = []
if params['free_first']:
order = ['-is_free', ]
if query.get('query') is None and query.get('category_subtree') == None:
# assumes no keywords and no category, thus an empty search that is triggered on start.
# orders by last core file upload
if query.get('verification_status') == 'uploaded':
# for validators, sort uploaded from oldest
order.append('created')
else:
order.append('-last_upload')
elif query.get('author_id') is not None and utils.profile_is_validator():
order.append('-created')
else:
if query.get('category_subtree') is not None:
order.append('-score,_score')
else:
order.append('_score')
requeststring += '+order:' + ','.join(order)
requeststring += '&addon_version=%s' % params['addon_version']
if params.get('scene_uuid') is not None:
requeststring += '&scene_uuid=%s' % params['scene_uuid']
# print('params', params)
urlquery = url + requeststring
return urlquery
def run(self):
maxthreads = 50
query = self.query
@ -961,22 +972,16 @@ class Searcher(threading.Thread):
t = time.time()
mt('search thread started')
tempdir = paths.get_temp_dir('%s_search' % query['asset_type'])
# tempdir = paths.get_temp_dir('%s_search' % query['asset_type'])
# json_filepath = os.path.join(tempdir, '%s_searchresult.json' % query['asset_type'])
headers = utils.get_headers(params['api_key'])
rdata = {}
rdata['results'] = []
if params['get_next']:
urlquery = self.result['next']
if not params['get_next']:
urlquery = self.query_to_url()
try:
utils.p(urlquery)
r = rerequests.get(urlquery, headers=headers) # , params = rparameters)
utils.p(self.urlquery)
r = rerequests.get(self.urlquery, headers=self.headers) # , params = rparameters)
# print(r.url)
reports = ''
# utils.p(r.text)
@ -1015,8 +1020,6 @@ class Searcher(threading.Thread):
# END OF PARSING
for d in rdata.get('results', []):
get_author(d)
for f in d['files']:
# TODO move validation of published assets to server, too manmy checks here.
if f['fileType'] == 'thumbnail' and f['fileThumbnail'] != None and f['fileThumbnailLarge'] != None:
@ -1029,11 +1032,11 @@ class Searcher(threading.Thread):
thumb_full_urls.append(f['fileThumbnailLarge'])
imgname = paths.extract_filename_from_url(f['fileThumbnail'])
imgpath = os.path.join(tempdir, imgname)
imgpath = os.path.join(self.tempdir, imgname)
thumb_small_filepaths.append(imgpath)
imgname = paths.extract_filename_from_url(f['fileThumbnailLarge'])
imgpath = os.path.join(tempdir, imgname)
imgpath = os.path.join(self.tempdir, imgname)
thumb_full_filepaths.append(imgpath)
sml_thbs = zip(thumb_small_filepaths, thumb_small_urls)
@ -1282,9 +1285,16 @@ def add_search_process(query, params, orig_result):
old_thread = search_threads.pop(0)
old_thread[0].stop()
# TODO CARE HERE FOR ALSO KILLING THE Thumbnail THREADS.?
# AT LEAST NOW SEARCH DONE FIRST WON'T REWRITE AN OLDER ONE
# AT LEAST NOW SEARCH DONE FIRST WON'T REWRITE AN NEWER ONE
tempdir = paths.get_temp_dir('%s_search' % query['asset_type'])
thread = Searcher(query, params, orig_result)
headers = utils.get_headers(params['api_key'])
if params['get_next']:
urlquery = orig_result['next']
if not params['get_next']:
urlquery = query_to_url(query, params)
thread = Searcher(query, params, orig_result, tempdir = tempdir, headers = headers, urlquery = urlquery)
thread.start()
search_threads.append([thread, tempdir, query['asset_type'], {}]) # 4th field is for results
@ -1394,7 +1404,7 @@ def search(category='', get_next=False, author_id=''):
return;
if category != '':
if utils.profile_is_validator():
if utils.profile_is_validator() and user_preferences.categories_fix:
query['category'] = category
else:
query['category_subtree'] = category

View File

@ -379,6 +379,7 @@ def draw_tooltip(x, y, text='', author='', img=None, gravatar=None):
tsize = font_height
else:
fsize = font_height
if l[:4] == 'Tip:' or l[:11] == 'Please rate':
tcol = textcol_strong
fsize = font_height + 1
@ -389,6 +390,7 @@ def draw_tooltip(x, y, text='', author='', img=None, gravatar=None):
xtext += int(isizex / ncolumns)
column_lines = 1
i=0
for l in alines:
if gravatar is not None:
if column_lines == 1:
@ -397,15 +399,20 @@ def draw_tooltip(x, y, text='', author='', img=None, gravatar=None):
xtext -= gsize + textmargin
ytext = y - column_lines * line_height - nameline_height - ttipmargin - textmargin - isizey + texth
if i == 0:
if False:#i == 0:
ytext = y - name_height + 5 - isizey + texth - textmargin
elif i == len(lines) - 1:
ytext = y - (nlines - 1) * line_height - nameline_height - ttipmargin * 2 - isizey + texth
tcol = textcol
tsize = font_height
if (i> 0 and alines[i-1][:7] == 'Author:'):
tcol = textcol_strong
fsize = font_height + 2
else:
fsize = font_height
if l[:4] == 'Tip:' or l[:11] == 'Please rate':
tcol = textcol
if l[:4] == 'Tip:' or l[:11] == 'Please rate' :
tcol = textcol_strong
fsize = font_height + 1
@ -1486,7 +1493,7 @@ class AssetBarOperator(bpy.types.Operator):
if ui_props.drag_init:
ui_props.drag_length += 1
if ui_props.drag_length > 0:
if ui_props.drag_length > 2:
ui_props.dragging = True
ui_props.drag_init = False
@ -1617,7 +1624,7 @@ class AssetBarOperator(bpy.types.Operator):
return {'RUNNING_MODAL'}
# Drag-drop interaction
if ui_props.dragging and mouse_in_region(r, mx, my):
if ui_props.dragging and mouse_in_region(r, mx, my):# and ui_props.drag_length>10:
asset_search_index = ui_props.active_index
# raycast here
ui_props.active_index = -3

View File

@ -143,14 +143,22 @@ def draw_upload_common(layout, props, asset_type, context):
layout.prop(props, 'is_private', expand=True)
if props.is_private == 'PUBLIC':
layout.prop(props, 'license')
layout.prop(props, 'is_free', expand=True)
prop_needed(layout, props, 'name', props.name)
if props.is_private == 'PUBLIC':
prop_needed(layout, props, 'description', props.description)
prop_needed(layout, props, 'tags', props.tags)
else:
layout.prop(props, 'description')
layout.prop(props, 'tags')
def poll_local_panels():
user_preferences = bpy.context.preferences.addons['blenderkit'].preferences
return user_preferences.panel_behaviour == 'BOTH' or user_preferences.panel_behaviour == 'LOCAL'
def prop_needed(layout, props, name, value, is_not_filled=''):
def prop_needed(layout, props, name, value='', is_not_filled=''):
row = layout.row()
if value == is_not_filled:
# row.label(text='', icon = 'ERROR')
@ -180,9 +188,6 @@ def draw_panel_hdr_upload(self, context):
draw_upload_common(layout, props, 'HDR', context)
layout.prop(props, 'name')
layout.prop(props, 'description')
layout.prop(props, 'tags')
def draw_panel_hdr_search(self, context):
@ -208,12 +213,10 @@ def draw_panel_model_upload(self, context):
draw_upload_common(layout, props, 'MODEL', context)
prop_needed(layout, props, 'name', props.name)
col = layout.column()
if props.is_generating_thumbnail:
col.enabled = False
prop_needed(col, props, 'thumbnail', props.has_thumbnail, False)
prop_needed(col, props, 'thumbnail', props.thumbnail)
if bpy.context.scene.render.engine in ('CYCLES', 'BLENDER_EEVEE'):
col.operator("object.blenderkit_generate_thumbnail", text='Generate thumbnail', icon='IMAGE')
@ -227,15 +230,12 @@ def draw_panel_model_upload(self, context):
elif props.thumbnail_generating_state != '':
utils.label_multiline(layout, text=props.thumbnail_generating_state)
layout.prop(props, 'description')
layout.prop(props, 'tags')
# prop_needed(layout, props, 'style', props.style)
# prop_needed(layout, props, 'production_level', props.production_level)
layout.prop(props, 'style')
layout.prop(props, 'production_level')
layout.prop(props, 'condition')
layout.prop(props, 'is_free')
layout.prop(props, 'pbr')
layout.label(text='design props:')
layout.prop(props, 'manufacturer')
@ -272,8 +272,6 @@ def draw_panel_scene_upload(self, context):
# else:
# layout.operator("object.blenderkit_auto_tags", text='Auto fill tags')
prop_needed(layout, props, 'name', props.name)
col = layout.column()
# if props.is_generating_thumbnail:
# col.enabled = False
@ -291,9 +289,8 @@ def draw_panel_scene_upload(self, context):
# elif props.thumbnail_generating_state != '':
# utils.label_multiline(layout, text = props.thumbnail_generating_state)
layout.prop(props, 'is_free')
layout.prop(props, 'description')
layout.prop(props, 'tags')
layout.prop(props, 'style')
layout.prop(props, 'production_level')
layout.prop(props, 'use_design_year')
@ -623,30 +620,16 @@ def draw_panel_material_upload(self, context):
draw_upload_common(layout, props, 'MATERIAL', context)
prop_needed(layout, props, 'name', props.name)
layout.prop(props, 'description')
layout.prop(props, 'style')
# if props.style == 'OTHER':
# layout.prop(props, 'style_other')
# layout.prop(props, 'engine')
# if props.engine == 'OTHER':
# layout.prop(props, 'engine_other')
layout.prop(props, 'tags')
# layout.prop(props,'shaders')#TODO autofill on upload
# row = layout.row()
layout.prop(props, 'is_free')
layout.prop(props, 'pbr')
layout.prop(props, 'uv')
layout.prop(props, 'animated')
layout.prop(props, 'texture_size_meters')
# THUMBNAIL
row = layout.row()
if props.is_generating_thumbnail:
row.enabled = False
prop_needed(row, props, 'thumbnail', props.has_thumbnail, False)
if bpy.context.scene.render.engine in ('CYCLES', 'BLENDER_EEVEE'):
layout.operator("object.blenderkit_material_thumbnail", text='Render thumbnail with Cycles', icon='EXPORT')
if props.is_generating_thumbnail:
row = layout.row(align=True)
row.label(text=props.thumbnail_generating_state, icon='RENDER_STILL')
@ -656,8 +639,21 @@ def draw_panel_material_upload(self, context):
elif props.thumbnail_generating_state != '':
utils.label_multiline(layout, text=props.thumbnail_generating_state)
if bpy.context.scene.render.engine in ('CYCLES', 'BLENDER_EEVEE'):
layout.operator("object.blenderkit_material_thumbnail", text='Render thumbnail with Cycles', icon='EXPORT')
layout.prop(props, 'style')
# if props.style == 'OTHER':
# layout.prop(props, 'style_other')
# layout.prop(props, 'engine')
# if props.engine == 'OTHER':
# layout.prop(props, 'engine_other')
# layout.prop(props,'shaders')#TODO autofill on upload
# row = layout.row()
layout.prop(props, 'pbr')
layout.prop(props, 'uv')
layout.prop(props, 'animated')
layout.prop(props, 'texture_size_meters')
# tname = "." + bpy.context.active_object.active_material.name + "_thumbnail"
# if props.has_thumbnail and bpy.data.textures.get(tname) is not None:
@ -701,10 +697,6 @@ def draw_panel_brush_upload(self, context):
draw_upload_common(layout, props, 'BRUSH', context)
layout.prop(props, 'name')
layout.prop(props, 'description')
layout.prop(props, 'tags')
def draw_panel_brush_search(self, context):
s = context.scene
@ -974,8 +966,20 @@ class VIEW3D_PT_blenderkit_unified(Panel):
row.scale_x = 1.6
row.scale_y = 1.6
# split = row.split(factor=.
col = layout.column()
col.prop(ui_props, 'asset_type', expand=True, icon_only=False)
expand_icon = 'TRIA_DOWN'
if ui_props.asset_type_expand:
expand_icon = 'TRIA_RIGHT'
row = layout.row()
split = row.split(factor = 0.1)
split.prop(ui_props, 'asset_type_expand', icon = expand_icon, icon_only = True, emboss = False)
split = split.split(factor = 1.0)
if ui_props.asset_type_expand:
#expanded interface with names in column
split = split.row()
split.scale_x = 1.6
split.scale_y = 1.6
split.prop(ui_props, 'asset_type', expand=True, icon_only=ui_props.asset_type_expand)
# row = layout.column(align = False)
# layout.prop(ui_props, 'asset_type', expand=False, text='')
@ -1588,9 +1592,10 @@ def draw_panel_categories(self, context):
# op.free_only = True
for c in cats['children']:
if c['assetCount'] > 0 or utils.profile_is_validator():
if c['assetCount'] > 0 or (utils.profile_is_validator() and user_preferences.categories_fix):
row = col.row(align=True)
if len(c['children']) > 0 and c['assetCount'] > 15 or utils.profile_is_validator():
if len(c['children']) > 0 and c['assetCount'] > 15 or (
utils.profile_is_validator() and user_preferences.categories_fix):
row = row.split(factor=.8, align=True)
# row = split.split()
ctext = '%s (%i)' % (c['name'], c['assetCount'])
@ -1603,8 +1608,8 @@ def draw_panel_categories(self, context):
op.do_search = True
op.keep_running = True
op.category = c['slug']
# TODO enable subcategories, now not working due to some bug on server probably
if len(c['children']) > 0 and c['assetCount'] > 15 or utils.profile_is_validator():
if len(c['children']) > 0 and c['assetCount'] > 15 or (
utils.profile_is_validator() and user_preferences.categories_fix):
# row = row.split()
op = row.operator('view3d.blenderkit_set_category', text='>>')
op.asset_type = ui_props.asset_type

View File

@ -18,7 +18,7 @@
from blenderkit import asset_inspector, paths, utils, bg_blender, autothumb, version_checker, search, ui_panels, ui, \
overrides, colors, rerequests, categories, upload_bg, tasks_queue, image_utils
overrides, colors, rerequests, categories, upload_bg, tasks_queue, image_utils
import tempfile, os, subprocess, json, re
@ -47,6 +47,7 @@ licenses = (
('cc_zero', 'Creative Commons Zero', 'Creative Commons Zero'),
)
def comma2array(text):
commasep = text.split(',')
ar = []
@ -71,50 +72,32 @@ def add_version(data):
def write_to_report(props, text):
props.report = props.report + text + '\n'
props.report = props.report + ' - '+ text + '\n\n'
def check_missing_data_model(props):
props.report = ''
autothumb.update_upload_model_preview(None, None)
if props.name == '':
write_to_report(props, 'Set model name')
# if props.tags == '':
# write_to_report(props, 'Write at least 3 tags')
if not props.has_thumbnail:
write_to_report(props, 'Add thumbnail:')
props.report += props.thumbnail_generating_state + '\n'
if props.engine == 'NONE':
write_to_report(props, 'Set at least one rendering/output engine')
if not any(props.dimensions):
write_to_report(props, 'Run autotags operator or fill in dimensions manually')
# if not any(props.dimensions):
# write_to_report(props, 'Run autotags operator or fill in dimensions manually')
def check_missing_data_scene(props):
props.report = ''
autothumb.update_upload_model_preview(None, None)
if props.name == '':
write_to_report(props, 'Set scene name')
# if props.tags == '':
# write_to_report(props, 'Write at least 3 tags')
if not props.has_thumbnail:
write_to_report(props, 'Add thumbnail:')
props.report += props.thumbnail_generating_state + '\n'
if props.engine == 'NONE':
write_to_report(props, 'Set at least one rendering/output engine')
def check_missing_data_material(props):
props.report = ''
autothumb.update_upload_material_preview(None, None)
if props.name == '':
write_to_report(props, 'Set material name')
# if props.tags == '':
# write_to_report(props, 'Write at least 3 tags')
if not props.has_thumbnail:
write_to_report(props, 'Add thumbnail:')
props.report += props.thumbnail_generating_state
@ -124,16 +107,56 @@ def check_missing_data_material(props):
def check_missing_data_brush(props):
autothumb.update_upload_brush_preview(None, None)
props.report = ''
if props.name == '':
write_to_report(props, 'Set brush name')
# if props.tags == '':
# write_to_report(props, 'Write at least 3 tags')
if not props.has_thumbnail:
write_to_report(props, 'Add thumbnail:')
props.report += props.thumbnail_generating_state
def check_missing_data(asset_type, props):
'''
checks if user did everything allright for particular assets and notifies him back if not.
Parameters
----------
asset_type
props
Returns
-------
'''
props.report = ''
if props.name == '':
write_to_report(props, f'Set {asset_type.lower()} name.\n'
f'It has to be in English and \n'
f'can not be longer than 40 letters.\n')
if len(props.name) > 40:
write_to_report(props, f'The name is too long. maximum is 40 letters')
if props.is_private == 'PUBLIC':
if len(props.description) < 20:
write_to_report(props, "The description is too short or empty. \n"
"Please write a description that describes \n "
"your asset as good as possible.\n"
"Description helps to bring your asset up\n in relevant search results. ")
if props.tags == '':
write_to_report(props, 'Write at least 3 tags.\n'
'Tags help to bring your asset up in relevant search results.')
if asset_type == 'MODEL':
check_missing_data_model(props)
if asset_type == 'SCENE':
check_missing_data_scene(props)
elif asset_type == 'MATERIAL':
check_missing_data_material(props)
elif asset_type == 'BRUSH':
check_missing_data_brush(props)
if props.report != '':
props.report = f'Please fix these issues before {props.is_private.lower()} upload:\n\n' + props.report
def sub_to_camel(content):
replaced = re.sub(r"_.",
lambda m: m.group(0)[1].upper(), content)
@ -514,8 +537,6 @@ def patch_individual_metadata(asset_id, metadata_dict, api_key):
return {'FINISHED'}
# class OBJECT_MT_blenderkit_fast_metadata_menu(bpy.types.Menu):
# bl_label = "Fast category change"
# bl_idname = "OBJECT_MT_blenderkit_fast_metadata_menu"
@ -540,13 +561,14 @@ def update_free_full(self, context):
if self.asset_type == 'material':
if self.free_full == 'FULL':
self.free_full = 'FREE'
ui_panels.ui_message(title = "All BlenderKit materials are free",
message = "Any material uploaded to BlenderKit is free." \
" However, it can still earn money for the author," \
" based on our fair share system. " \
"Part of subscription is sent to artists based on usage by paying users.")
ui_panels.ui_message(title="All BlenderKit materials are free",
message="Any material uploaded to BlenderKit is free." \
" However, it can still earn money for the author," \
" based on our fair share system. " \
"Part of subscription is sent to artists based on usage by paying users.")
def can_edit_asset(active_index = -1, asset_data = None):
def can_edit_asset(active_index=-1, asset_data=None):
if active_index == -1 and not asset_data:
return False
profile = bpy.context.window_manager.get('bkit profile')
@ -562,6 +584,7 @@ def can_edit_asset(active_index = -1, asset_data = None):
return True
return False
class FastMetadata(bpy.types.Operator):
"""Fast change of the category of object directly in asset bar."""
bl_idname = "wm.blenderkit_fast_metadata"
@ -597,7 +620,7 @@ class FastMetadata(bpy.types.Operator):
name="Subcategory",
description="main category to put into",
items=categories.get_subcategory_enums,
update = categories.update_subcategory_enums
update=categories.update_subcategory_enums
)
subcategory1: EnumProperty(
name="Subcategory",
@ -620,7 +643,7 @@ class FastMetadata(bpy.types.Operator):
default="PUBLIC",
)
free_full:EnumProperty(
free_full: EnumProperty(
name="Free or Full Plan",
items=(
('FREE', 'Free', "You consent you want to release this asset as free for everyone"),
@ -648,7 +671,7 @@ class FastMetadata(bpy.types.Operator):
layout.prop(self, 'subcategory')
if self.subcategory != 'NONE' and self.subcategory1 != 'NONE':
enums = categories.get_subcategory1_enums(self, context)
if enums[0][0]!='NONE':
if enums[0][0] != 'NONE':
layout.prop(self, 'subcategory1')
layout.prop(self, 'name')
layout.prop(self, 'description')
@ -658,8 +681,6 @@ class FastMetadata(bpy.types.Operator):
if self.is_private == 'PUBLIC':
layout.prop(self, 'license')
def execute(self, context):
user_preferences = bpy.context.preferences.addons['blenderkit'].preferences
props = bpy.context.scene.blenderkitUI
@ -685,7 +706,7 @@ class FastMetadata(bpy.types.Operator):
args=(self.asset_id, mdict, user_preferences.api_key))
thread.start()
tasks_queue.add_task((ui.add_report, (f'Uploading metadata for {self.name}. '
f'Refreash search results to see that changes applied correctly.', 8,)))
f'Refresh search results to see that changes applied correctly.', 8,)))
return {'FINISHED'}
@ -730,7 +751,7 @@ class FastMetadata(bpy.types.Operator):
wm = context.window_manager
return wm.invoke_props_dialog(self, width = 600)
return wm.invoke_props_dialog(self, width=600)
def verification_status_change_thread(asset_id, state, api_key):
@ -944,7 +965,7 @@ class Uploader(threading.Thread):
}
datafile = os.path.join(self.export_data['temp_dir'], BLENDERKIT_EXPORT_DATA_FILE)
with open(datafile, 'w', encoding = 'utf-8') as s:
with open(datafile, 'w', encoding='utf-8') as s:
json.dump(data, s, ensure_ascii=False, indent=4)
# non waiting method - not useful here..
@ -987,7 +1008,7 @@ class Uploader(threading.Thread):
})
if not os.path.exists(fpath):
self.send_message ("File packing failed, please try manual packing first")
self.send_message("File packing failed, please try manual packing first")
return {'CANCELLED'}
self.send_message('Uploading files')
@ -1018,28 +1039,6 @@ class Uploader(threading.Thread):
return {'CANCELLED'}
def check_missing_data(asset_type, props):
'''
checks if user did everything allright for particular assets and notifies him back if not.
Parameters
----------
asset_type
props
Returns
-------
'''
if asset_type == 'MODEL':
check_missing_data_model(props)
if asset_type == 'SCENE':
check_missing_data_scene(props)
elif asset_type == 'MATERIAL':
check_missing_data_material(props)
elif asset_type == 'BRUSH':
check_missing_data_brush(props)
def start_upload(self, context, asset_type, reupload, upload_set):
'''start upload process, by processing data, then start a thread that cares about the rest of the upload.'''
@ -1065,7 +1064,6 @@ def start_upload(self, context, asset_type, reupload, upload_set):
check_missing_data(asset_type, props)
# if previous check did find any problems then
if props.report != '':
self.report({'ERROR_INVALID_INPUT'}, props.report)
return {'CANCELLED'}
if not reupload:
@ -1188,14 +1186,18 @@ class UploadOperator(Operator):
if self.main_file:
upload_set.append('MAINFILE')
#this is accessed later in get_upload_data and needs to be written.
# this is accessed later in get_upload_data and needs to be written.
# should pass upload_set all the way to it probably
if 'MAINFILE' in upload_set:
self.main_file = True
result = start_upload(self, context, self.asset_type, self.reupload, upload_set=upload_set, )
return {'FINISHED'}
if props.report != '':
# self.report({'ERROR_INVALID_INPUT'}, props.report)
self.report({'ERROR_INVALID_CONTEXT'}, props.report)
return result
def draw(self, context):
props = utils.get_upload_props()

View File

@ -91,6 +91,11 @@ def upload_file(upload_data, f):
if 250 > upload_response.status_code > 199:
uploaded = True
upload_done_url = paths.get_api_url() + 'uploads_s3/' + upload['id'] + '/upload-file/'
upload_response = rerequests.post(upload_done_url, headers=headers, verify=True)
print(upload_response)
tasks_queue.add_task((ui.add_report, (f"Finished file upload{os.path.basename(f['file_path'])}",)))
return True
else:
print(upload_response.text)
message = f"Upload failed, retry. File : {f['type']} {os.path.basename(f['file_path'])}"
@ -103,13 +108,11 @@ def upload_file(upload_data, f):
time.sleep(1)
# confirm single file upload to bkit server
print(upload)
upload_done_url = paths.get_api_url() + 'uploads_s3/' + upload['id'] + '/upload-file/'
upload_response = rerequests.post(upload_done_url, headers=headers, verify=True)
tasks_queue.add_task((ui.add_report, (f"Finished file upload{os.path.basename(f['file_path'])}",)))
return uploaded
return False
def upload_files(upload_data, files):

View File

@ -366,12 +366,12 @@ def get_thumbnail(name):
def files_size_to_text(size):
fsmb = size // (1024 * 1024)
fsmb = size / (1024 * 1024)
fskb = size % 1024
if fsmb == 0:
return f'{fskb}KB'
return f'{round(fskb)}KB'
else:
return f'{fsmb}MB {fskb}KB'
return f'{round(fsmb, 1)}MB'
def get_brush_props(context):

View File

@ -560,10 +560,12 @@ def register():
# Add shortcuts to the keymap.
wm = bpy.context.window_manager
km = wm.keyconfigs.addon.keymaps.new(name='Pose')
kmi = km.keymap_items.new('wm.call_menu', 'W', 'PRESS', alt=True, shift=True)
kmi.properties.name = 'POSE_MT_selection_sets_select'
addon_keymaps.append((km, kmi))
if wm.keyconfigs.addon is not None:
# wm.keyconfigs.addon is None when Blender is running in the background.
km = wm.keyconfigs.addon.keymaps.new(name='Pose')
kmi = km.keymap_items.new('wm.call_menu', 'W', 'PRESS', alt=True, shift=True)
kmi.properties.name = 'POSE_MT_selection_sets_select'
addon_keymaps.append((km, kmi))
# Add entries to menus.
bpy.types.VIEW3D_MT_select_pose.append(menu_func_select_selection_set)

View File

@ -21,7 +21,7 @@ bl_info = {
"name": "Grease Pencil Tools",
"description": "Extra tools for Grease Pencil",
"author": "Samuel Bernou, Antonio Vazquez, Daniel Martinez Lara, Matias Mendiola",
"version": (1, 3, 4),
"version": (1, 4, 0),
"blender": (2, 91, 0),
"location": "Sidebar > Grease Pencil > Grease Pencil Tools",
"warning": "",

View File

@ -123,6 +123,7 @@ class GPTS_OT_time_scrub(bpy.types.Operator):
self.key = prefs.keycode
self.evaluate_gp_obj_key = prefs.evaluate_gp_obj_key
self.always_snap = prefs.always_snap
self.rolling_mode = prefs.rolling_mode
self.dpi = context.preferences.system.dpi
self.ui_scale = context.preferences.system.ui_scale
@ -146,7 +147,7 @@ class GPTS_OT_time_scrub(bpy.types.Operator):
self.init_mouse_x = self.cursor_x = event.mouse_region_x
# self.init_mouse_y = event.mouse_region_y # only to display init frame text
self.init_frame = self.new_frame = context.scene.frame_current
self.cancel_frame = self.init_frame = self.new_frame = context.scene.frame_current
self.lock_range = context.scene.lock_frame_selection_to_range
if context.scene.use_preview_range:
self.f_start = context.scene.frame_preview_start
@ -195,11 +196,28 @@ class GPTS_OT_time_scrub(bpy.types.Operator):
if not ob or not self.pos:
# Disable inverted behavior if no frame to snap
self.always_snap = False
if self.rolling_mode:
self.report({'WARNING'}, 'No Keys to flip on')
return {'CANCELLED'}
if self.rolling_mode:
# sorted and casted to int list since it's going to work with indexes
self.pos = sorted([int(f) for f in self.pos])
# find and make current frame the "starting" frame (force snap)
active_pos = [i for i, num in enumerate(self.pos) if num <= self.init_frame]
if active_pos:
self.init_index = active_pos[-1]
self.init_frame = self.new_frame = self.pos[self.init_index]
else:
self.init_index = 0
self.init_frame = self.new_frame = self.pos[0]
# del active_pos
self.index_limit = len(self.pos) - 1
# Also snap on play bounds (sliced off for keyframe display)
self.pos += [self.f_start, self.f_end]
# Disable Onion skin
self.active_space_data = context.space_data
self.onion_skin = None
@ -241,43 +259,44 @@ class GPTS_OT_time_scrub(bpy.types.Operator):
self.hud_lines = []
# frame marks
for x in hud_pos_x:
self.hud_lines.append((x, my - (frame_height/2)))
self.hud_lines.append((x, my + (frame_height/2)))
if not self.rolling_mode:
# frame marks
for x in hud_pos_x:
self.hud_lines.append((x, my - (frame_height/2)))
self.hud_lines.append((x, my + (frame_height/2)))
# init frame mark
self.hud_lines += [(self.init_mouse_x, my - (init_height/2)),
(self.init_mouse_x, my + (init_height/2))]
# Add start/end boundary bracket to HUD
if not self.rolling_mode:
# Add start/end boundary bracket to HUD
start_x = self.init_mouse_x + \
(self.f_start - self.init_frame) * self.px_step
end_x = self.init_mouse_x + \
(self.f_end - self.init_frame) * self.px_step
start_x = self.init_mouse_x + \
(self.f_start - self.init_frame) * self.px_step
end_x = self.init_mouse_x + \
(self.f_end - self.init_frame) * self.px_step
# start
up = (start_x, my - (bound_h/2))
dn = (start_x, my + (bound_h/2))
self.hud_lines.append(up)
self.hud_lines.append(dn)
# start
up = (start_x, my - (bound_h/2))
dn = (start_x, my + (bound_h/2))
self.hud_lines.append(up)
self.hud_lines.append(dn)
self.hud_lines.append(up)
self.hud_lines.append((up[0] + bound_bracket_l, up[1]))
self.hud_lines.append(dn)
self.hud_lines.append((dn[0] + bound_bracket_l, dn[1]))
self.hud_lines.append(up)
self.hud_lines.append((up[0] + bound_bracket_l, up[1]))
self.hud_lines.append(dn)
self.hud_lines.append((dn[0] + bound_bracket_l, dn[1]))
# end
up = (end_x, my - (bound_h/2))
dn = (end_x, my + (bound_h/2))
self.hud_lines.append(up)
self.hud_lines.append(dn)
# end
up = (end_x, my - (bound_h/2))
dn = (end_x, my + (bound_h/2))
self.hud_lines.append(up)
self.hud_lines.append(dn)
self.hud_lines.append(up)
self.hud_lines.append((up[0] - bound_bracket_l, up[1]))
self.hud_lines.append(dn)
self.hud_lines.append((dn[0] - bound_bracket_l, dn[1]))
self.hud_lines.append(up)
self.hud_lines.append((up[0] - bound_bracket_l, up[1]))
self.hud_lines.append(dn)
self.hud_lines.append((dn[0] - bound_bracket_l, dn[1]))
# Horizontal line
self.hud_lines += [(0, my), (width, my)]
@ -286,16 +305,24 @@ class GPTS_OT_time_scrub(bpy.types.Operator):
shader = gpu.shader.from_builtin('2D_UNIFORM_COLOR') # initiate shader
self.batch_timeline = batch_for_shader(
shader, 'LINES', {"pos": self.hud_lines})
if self.rolling_mode:
current_id = self.pos.index(self.new_frame)
# Add init_frame to "cancel" it in later UI code
ui_key_pos = [i - current_id + self.init_frame for i, _f in enumerate(self.pos[:-2])]
else:
ui_key_pos = self.pos[:-2]
# keyframe display
if self.keyframe_aspect == 'LINE':
key_lines = []
# Slice off position of start/end frame added last (in list for snapping)
for i in self.pos[:-2]:
for i in ui_key_pos:
key_lines.append(
(self.init_mouse_x + ((i-self.init_frame) * self.px_step), my - (key_height/2)))
key_lines.append(
(self.init_mouse_x + ((i-self.init_frame)*self.px_step), my + (key_height/2)))
(self.init_mouse_x + ((i-self.init_frame) * self.px_step), my + (key_height/2)))
self.batch_keyframes = batch_for_shader(
shader, 'LINES', {"pos": key_lines})
@ -309,7 +336,7 @@ class GPTS_OT_time_scrub(bpy.types.Operator):
shaped_key = []
indices = []
idx_offset = 0
for i in self.pos[:-2]:
for i in ui_key_pos:
center = self.init_mouse_x + ((i-self.init_frame)*self.px_step)
if self.keyframe_aspect == 'DIAMOND':
# +1 on x is to correct pixel alignement
@ -339,6 +366,8 @@ class GPTS_OT_time_scrub(bpy.types.Operator):
# convert frame list to array for numpy snap utility
self.pos = np.asarray(self.pos)
if self.rolling_mode:
context.scene.frame_current = self.new_frame
args = (self, context)
self.viewtype = None
@ -383,42 +412,52 @@ class GPTS_OT_time_scrub(bpy.types.Operator):
self.offset = int(px_offset / self.px_step)
self.new_frame = self.init_frame + self.offset
mod_snap = False
if self.snap_ctrl and event.ctrl:
mod_snap = True
if self.snap_shift and event.shift:
mod_snap = True
if self.snap_alt and event.alt:
mod_snap = True
if self.rolling_mode:
# Frame Flipping mode (equidistant scrub snap)
self.index = self.init_index + self.offset
# clamp to possible index range
self.index = min(max(self.index, 0), self.index_limit)
self.new_frame = self.pos[self.index]
context.scene.frame_current = self.new_frame
self.cursor_x = self.init_mouse_x + (self.offset * self.px_step)
## Snapping
if self.always_snap:
# inverted snapping behavior
if not self.snap_on and not mod_snap:
self.new_frame = nearest(self.pos, self.new_frame)
else:
if self.snap_on or mod_snap:
self.new_frame = nearest(self.pos, self.new_frame)
mod_snap = False
if self.snap_ctrl and event.ctrl:
mod_snap = True
if self.snap_shift and event.shift:
mod_snap = True
if self.snap_alt and event.alt:
mod_snap = True
# frame range restriction
if self.lock_range:
if self.new_frame < self.f_start:
self.new_frame = self.f_start
elif self.new_frame > self.f_end:
self.new_frame = self.f_end
## Snapping
if self.always_snap:
# inverted snapping behavior
if not self.snap_on and not mod_snap:
self.new_frame = nearest(self.pos, self.new_frame)
else:
if self.snap_on or mod_snap:
self.new_frame = nearest(self.pos, self.new_frame)
# context.scene.frame_set(self.new_frame)
context.scene.frame_current = self.new_frame
# frame range restriction
if self.lock_range:
if self.new_frame < self.f_start:
self.new_frame = self.f_start
elif self.new_frame > self.f_end:
self.new_frame = self.f_end
# - recalculate offset to snap cursor to frame
self.offset = self.new_frame - self.init_frame
# context.scene.frame_set(self.new_frame)
context.scene.frame_current = self.new_frame
# - calculate cursor pixel position from frame offset
self.cursor_x = self.init_mouse_x + (self.offset * self.px_step)
# - recalculate offset to snap cursor to frame
self.offset = self.new_frame - self.init_frame
# - calculate cursor pixel position from frame offset
self.cursor_x = self.init_mouse_x + (self.offset * self.px_step)
if event.type == 'ESC':
# frame_set(self.init_frame) ?
context.scene.frame_current = self.init_frame
context.scene.frame_current = self.cancel_frame
self._exit_modal(context)
return {'CANCELLED'}
@ -505,6 +544,11 @@ class GPTS_timeline_settings(bpy.types.PropertyGroup):
description="Always snap to keys if any, modifier is used deactivate the snapping\nDisabled if no keyframe found",
default=False)
rolling_mode: BoolProperty(
name="Rolling Mode",
description="Alternative Gap-less timeline. No time informations to quickly roll/flip over keys\nOverride normal and 'always snap' mode",
default=False)
use_in_timeline_editor: BoolProperty(
name="Shortcut in timeline editors",
description="Add the same shortcut to scrub in timeline editor windows",
@ -666,7 +710,7 @@ def draw_ts_pref(prefs, layout):
snap_text = 'Disable keyframes snap: '
else:
snap_text = 'Keyframes snap: '
snap_text += 'Left Mouse' if prefs.keycode == 'RIGHTMOUSE' else 'Right Mouse'
if not prefs.use_ctrl:
snap_text += ' or Ctrl'
@ -674,12 +718,18 @@ def draw_ts_pref(prefs, layout):
snap_text += ' or Shift'
if not prefs.use_alt:
snap_text += ' or Alt'
if prefs.rolling_mode:
snap_text = 'Gap-less mode (always snap)'
box.label(text=snap_text, icon='SNAP_ON')
if prefs.keycode in ('LEFTMOUSE', 'RIGHTMOUSE', 'MIDDLEMOUSE') and not prefs.use_ctrl and not prefs.use_alt and not prefs.use_shift:
box.label(
text="Recommended to choose at least one modifier to combine with clicks (default: Ctrl+Alt)", icon="ERROR")
box.prop(prefs, 'always_snap')
row = box.row()
row.prop(prefs, 'always_snap')
row.prop(prefs, 'rolling_mode')
box.prop(prefs, 'use_in_timeline_editor',
text='Add same shortcut to scrub within timeline editors')

View File

@ -22,7 +22,7 @@ bl_info = {
"name": "Collection Manager",
"description": "Manage collections and their objects",
"author": "Ryan Inch",
"version": (2, 19, 3),
"version": (2, 21, 0),
"blender": (2, 80, 0),
"location": "View3D - Object Mode (Shortcut - M)",
"warning": '', # used for warning icon and text in addons panel

View File

@ -77,6 +77,7 @@ addon_disable_objects_hotkey_keymaps = []
classes = (
internals.CMListCollection,
internals.CMSendReport,
internals.CMUISeparatorButton,
operators.SetActiveCollection,
operators.ExpandAllOperator,
operators.ExpandSublevelOperator,
@ -104,6 +105,8 @@ classes = (
operators.CMDisableObjectsOperator,
operators.CMDisableUnSelectedObjectsOperator,
operators.CMRestoreDisabledObjectsOperator,
operators.CMUndoWrapper,
operators.CMRedoWrapper,
preferences.CMPreferences,
ui.CM_UL_items,
ui.CollectionManager,

View File

@ -621,6 +621,80 @@ def generate_state(*, qcd=False):
return state
def check_state(context, *, cm_popup=False, phantom_mode=False, qcd=False):
view_layer = context.view_layer
# check if expanded & history/buffer state still correct
if cm_popup and collection_state:
new_state = generate_state()
if new_state["name"] != collection_state["name"]:
copy_buffer["RTO"] = ""
copy_buffer["values"].clear()
swap_buffer["A"]["RTO"] = ""
swap_buffer["A"]["values"].clear()
swap_buffer["B"]["RTO"] = ""
swap_buffer["B"]["values"].clear()
for name in list(expanded):
laycol = layer_collections.get(name)
if not laycol or not laycol["has_children"]:
expanded.remove(name)
for name in list(expand_history["history"]):
laycol = layer_collections.get(name)
if not laycol or not laycol["has_children"]:
expand_history["history"].remove(name)
for rto, history in rto_history.items():
if view_layer.name in history:
del history[view_layer.name]
else:
for rto in ["exclude", "select", "hide", "disable", "render", "holdout", "indirect"]:
if new_state[rto] != collection_state[rto]:
if view_layer.name in rto_history[rto]:
del rto_history[rto][view_layer.name]
if view_layer.name in rto_history[rto+"_all"]:
del rto_history[rto+"_all"][view_layer.name]
if phantom_mode:
cm = context.scene.collection_manager
# check if in phantom mode and if it's still viable
if cm.in_phantom_mode:
if layer_collections.keys() != phantom_history["initial_state"].keys():
cm.in_phantom_mode = False
if view_layer.name != phantom_history["view_layer"]:
cm.in_phantom_mode = False
if not cm.in_phantom_mode:
for key, value in phantom_history.items():
try:
value.clear()
except AttributeError:
if key == "view_layer":
phantom_history["view_layer"] = ""
if qcd and qcd_collection_state:
from .qcd_operators import QCDAllBase
new_state = generate_state(qcd=True)
if (new_state["name"] != qcd_collection_state["name"]
or new_state["exclude"] != qcd_collection_state["exclude"]
or new_state["qcd"] != qcd_collection_state["qcd"]):
if view_layer.name in qcd_history:
del qcd_history[view_layer.name]
qcd_collection_state.clear()
QCDAllBase.clear()
def get_move_selection(*, names_only=False):
global move_selection
@ -720,3 +794,30 @@ def send_report(message):
bpy.ops.view3d.cm_send_report(ctx, 'INVOKE_DEFAULT', message=message)
bpy.app.timers.register(report)
class CMUISeparatorButton(Operator):
bl_label = "UI Separator Button"
bl_idname = "view3d.cm_ui_separator_button"
def execute(self, context):
return {'CANCELED'}
def add_vertical_separator_line(row):
# add buffer before to account for scaling
separator = row.row()
separator.scale_x = 0.1
separator.label()
# add separator line
separator = row.row()
separator.scale_x = 0.2
separator.enabled = False
separator.operator("view3d.cm_ui_separator_button",
text="",
icon='BLANK1',
)
# add buffer after to account for scaling
separator = row.row()
separator.scale_x = 0.1
separator.label()

View File

@ -38,6 +38,8 @@ from . import internals
# For FUNCTIONS
from .internals import (
update_property_group,
generate_state,
check_state,
get_modifiers,
get_move_selection,
get_move_active,
@ -1499,3 +1501,48 @@ class CMRestoreDisabledObjectsOperator(Operator):
obj.select_set(True)
return {'FINISHED'}
class CMUndoWrapper(Operator):
bl_label = "Undo"
bl_description = "Undo previous action"
bl_idname = "view3d.undo_wrapper"
@classmethod
def poll(self, context):
return bpy.ops.ed.undo.poll()
def execute(self, context):
internals.collection_state.clear()
internals.collection_state.update(generate_state())
bpy.ops.ed.undo()
update_property_group(context)
check_state(context, cm_popup=True)
# clear buffers
internals.copy_buffer["RTO"] = ""
internals.copy_buffer["values"].clear()
internals.swap_buffer["A"]["RTO"] = ""
internals.swap_buffer["A"]["values"].clear()
internals.swap_buffer["B"]["RTO"] = ""
internals.swap_buffer["B"]["values"].clear()
return {'FINISHED'}
class CMRedoWrapper(Operator):
bl_label = "Redo"
bl_description = "Redo previous action"
bl_idname = "view3d.redo_wrapper"
@classmethod
def poll(self, context):
return bpy.ops.ed.redo.poll()
def execute(self, context):
bpy.ops.ed.redo()
update_property_group(context)
return {'FINISHED'}

View File

@ -40,16 +40,13 @@ from .internals import (
update_collection_tree,
update_property_group,
generate_state,
check_state,
get_move_selection,
get_move_active,
update_qcd_header,
add_vertical_separator_line,
)
from .qcd_operators import (
QCDAllBase,
)
preview_collections = {}
last_icon_theme_text = None
last_icon_theme_text_sel = None
@ -79,6 +76,7 @@ class CollectionManager(Operator):
cm = context.scene.collection_manager
prefs = context.preferences.addons[__package__].preferences
view_layer = context.view_layer
collection = context.view_layer.layer_collection.collection
if view_layer.name != cls.last_view_layer:
if prefs.enable_qcd:
@ -136,6 +134,11 @@ class CollectionManager(Operator):
renum_sec.alignment = 'LEFT'
renum_sec.operator("view3d.renumerate_qcd_slots")
undo_sec = op_sec.row(align=True)
undo_sec.alignment = 'LEFT'
undo_sec.operator("view3d.undo_wrapper", text="", icon='LOOP_BACK')
undo_sec.operator("view3d.redo_wrapper", text="", icon='LOOP_FORWARDS')
# menu & filter
right_sec = button_row_1.row()
right_sec.alignment = 'RIGHT'
@ -162,16 +165,52 @@ class CollectionManager(Operator):
master_collection_row.separator()
# name
name_row = master_collection_row.row()
name_row.prop(self, "master_collection", text='')
name_row.enabled = False
name_row = master_collection_row.row(align=True)
name_field = name_row.row(align=True)
name_field.prop(self, "master_collection", text='')
name_field.enabled = False
# set selection
setsel = name_row.row(align=True)
icon = 'DOT'
if any((laycol["ptr"].exclude,
collection.hide_select,
collection.hide_viewport,
laycol["ptr"].hide_viewport,
not collection.objects,)):
# objects cannot be selected
setsel.active = False
else:
for obj in collection.objects:
if obj.select_get() == False:
# some objects remain unselected
icon = 'LAYER_USED'
break
if icon != 'LAYER_USED':
# all objects are selected
icon = 'LAYER_ACTIVE'
prop = setsel.operator("view3d.select_collection_objects",
text="",
icon=icon,
depress=bool(icon == 'LAYER_ACTIVE')
)
prop.is_master_collection = True
prop.collection_name = 'Master Collection'
master_collection_row.separator()
# global rtos
global_rto_row = master_collection_row.row()
global_rto_row.alignment = 'RIGHT'
# used as a separator (actual separator not wide enough)
global_rto_row.label()
# set collection
row_setcol = global_rto_row.row()
row_setcol.alignment = 'LEFT'
row_setcol.operator_context = 'INVOKE_DEFAULT'
@ -183,7 +222,10 @@ class CollectionManager(Operator):
collection = context.view_layer.layer_collection.collection
icon = 'MESH_CUBE'
icon = 'GRID'
if collection.objects:
icon = 'MESH_CUBE'
if selected_objects:
if active_object and active_object.name in collection.objects:
@ -193,13 +235,33 @@ class CollectionManager(Operator):
icon = 'STICKY_UVS_LOC'
else:
row_setcol.enabled = False
row_setcol.active = False
# add vertical separator line
separator = row_setcol.row()
separator.scale_x = 0.2
separator.enabled = False
separator.operator("view3d.cm_ui_separator_button",
text="",
icon='BLANK1',
)
# add operator
prop = row_setcol.operator("view3d.set_collection", text="",
icon=icon, emboss=False)
prop.is_master_collection = True
prop.collection_name = 'Master Collection'
# add vertical separator line
separator = row_setcol.row()
separator.scale_x = 0.2
separator.enabled = False
separator.operator("view3d.cm_ui_separator_button",
text="",
icon='BLANK1',
)
copy_icon = 'COPYDOWN'
swap_icon = 'ARROW_LEFTRIGHT'
copy_swap_icon = 'SELECT_INTERSECT'
@ -387,58 +449,8 @@ class CollectionManager(Operator):
if cm.cm_list_index >= len(cm.cm_list_collection):
cm.cm_list_index = -1
# check if expanded & history/buffer state still correct
if internals.collection_state:
new_state = generate_state()
if new_state["name"] != internals.collection_state["name"]:
internals.copy_buffer["RTO"] = ""
internals.copy_buffer["values"].clear()
internals.swap_buffer["A"]["RTO"] = ""
internals.swap_buffer["A"]["values"].clear()
internals.swap_buffer["B"]["RTO"] = ""
internals.swap_buffer["B"]["values"].clear()
for name in list(internals.expanded):
laycol = internals.layer_collections.get(name)
if not laycol or not laycol["has_children"]:
internals.expanded.remove(name)
for name in list(internals.expand_history["history"]):
laycol = internals.layer_collections.get(name)
if not laycol or not laycol["has_children"]:
internals.expand_history["history"].remove(name)
for rto, history in internals.rto_history.items():
if view_layer.name in history:
del history[view_layer.name]
else:
for rto in ["exclude", "select", "hide", "disable", "render", "holdout", "indirect"]:
if new_state[rto] != internals.collection_state[rto]:
if view_layer.name in internals.rto_history[rto]:
del internals.rto_history[rto][view_layer.name]
if view_layer.name in internals.rto_history[rto+"_all"]:
del internals.rto_history[rto+"_all"][view_layer.name]
# check if in phantom mode and if it's still viable
if cm.in_phantom_mode:
if internals.layer_collections.keys() != internals.phantom_history["initial_state"].keys():
cm.in_phantom_mode = False
if view_layer.name != internals.phantom_history["view_layer"]:
cm.in_phantom_mode = False
if not cm.in_phantom_mode:
for key, value in internals.phantom_history.items():
try:
value.clear()
except AttributeError:
if key == "view_layer":
internals.phantom_history["view_layer"] = ""
# check if history/buffer/phantom state still correct
check_state(context, cm_popup=True, phantom_mode=True)
# handle window sizing
max_width = 960
@ -583,7 +595,8 @@ class CM_UL_items(UIList):
QCD.scale_x = 0.4
QCD.prop(item, "qcd_slot_idx", text="")
c_name = row.row()
# collection name
c_name = row.row(align=True)
#if rename[0] and index == cm.cm_list_index:
#c_name.activate_init = True
@ -591,16 +604,54 @@ class CM_UL_items(UIList):
c_name.prop(item, "name", text="", expand=True)
# set selection
setsel = c_name.row(align=True)
icon = 'DOT'
if any((laycol["ptr"].exclude,
collection.hide_select,
collection.hide_viewport,
laycol["ptr"].hide_viewport,
not collection.objects,)):
# objects cannot be selected
setsel.active = False
else:
for obj in collection.objects:
if obj.select_get() == False:
# some objects remain unselected
icon = 'LAYER_USED'
break
if icon != 'LAYER_USED':
# all objects are selected
icon = 'LAYER_ACTIVE'
prop = setsel.operator("view3d.select_collection_objects",
text="",
icon=icon,
depress=bool(icon == 'LAYER_ACTIVE')
)
prop.is_master_collection = False
prop.collection_name = item.name
# used as a separator (actual separator not wide enough)
row.label()
row = s2 if cm.align_local_ops else s1
add_vertical_separator_line(row)
# add set_collection op
set_obj_col = row.row()
set_obj_col.operator_context = 'INVOKE_DEFAULT'
icon = 'MESH_CUBE'
icon = 'GRID'
if collection.objects:
icon = 'MESH_CUBE'
if selected_objects:
if active_object and active_object.name in collection.objects:
@ -618,6 +669,8 @@ class CM_UL_items(UIList):
prop.is_master_collection = False
prop.collection_name = item.name
add_vertical_separator_line(row)
if cm.show_exclude:
exclude_history_base = internals.rto_history["exclude"].get(view_layer.name, {})
@ -930,17 +983,7 @@ def view3d_header_qcd_slots(self, context):
layout = self.layout
idx = 1
if internals.qcd_collection_state:
view_layer = context.view_layer
new_state = generate_state(qcd=True)
if (new_state["name"] != internals.qcd_collection_state["name"]
or new_state["exclude"] != internals.qcd_collection_state["exclude"]
or new_state["qcd"] != internals.qcd_collection_state["qcd"]):
if view_layer.name in internals.qcd_history:
del internals.qcd_history[view_layer.name]
internals.qcd_collection_state.clear()
QCDAllBase.clear()
check_state(context, qcd=True)
main_row = layout.row(align=True)

View File

@ -818,6 +818,7 @@
metadatabg="#333333"
metadatatext="#b8b8b8"
preview_range="#a14d0066"
row_alternate="#ffffff0d"
>
<space>
<ThemeSpaceGeneric
@ -854,6 +855,7 @@
<properties>
<ThemeProperties
match="#c85130"
active_modifier="#c85130ff"
>
<space>
<ThemeSpaceGeneric
@ -962,6 +964,8 @@
script_node="#141414"
pattern_node="#008db6"
layout_node="#3c3c3c"
geometry_node="#00d7a4"
attribute_node="#3f5980"
>
<space>
<ThemeSpaceGeneric
@ -1312,6 +1316,42 @@
</space>
</ThemeStatusBar>
</statusbar>
<spreadsheet>
<ThemeSpreadsheet
row_alternate="#4f4f4fff"
>
<space>
<ThemeSpaceGeneric
back="#474747"
title="#ffffff"
text="#c3c3c3"
text_hi="#ffffff"
header="#454545ff"
header_text="#eeeeee"
header_text_hi="#ffffff"
button="#424242ff"
button_title="#ffffff"
button_text="#e5e5e5"
button_text_hi="#ffffff"
navigation_bar="#00000000"
execution_buts="#00000000"
tab_active="#4b4b4b"
tab_inactive="#2b2b2b"
tab_back="#232323ff"
tab_outline="#232323"
>
<panelcolors>
<ThemePanelColors
header="#424242cc"
back="#333333b3"
sub_back="#0000003e"
>
</ThemePanelColors>
</panelcolors>
</ThemeSpaceGeneric>
</space>
</ThemeSpreadsheet>
</spreadsheet>
<bone_color_sets>
<ThemeBoneColorSet
normal="#9a0000"

View File

@ -41,6 +41,22 @@ class PIE_MT_SaveOpen(Menu):
bl_idname = "PIE_MT_saveopen"
bl_label = "Pie Save/Open"
@staticmethod
def _save_as_mainfile_calc_incremental_name():
import re
dirname, base_name = os.path.split(bpy.data.filepath)
base_name_no_ext, ext = os.path.splitext(base_name)
match = re.match(r"(.*)_([\d]+)$", base_name_no_ext)
if match:
prefix, number = match.groups()
number = int(number) + 1
else:
prefix, number = base_name_no_ext, 1
prefix = os.path.join(dirname, prefix)
while os.path.isfile(output := "%s_%03d%s" % (prefix, number, ext)):
number += 1
return output
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
@ -57,7 +73,16 @@ class PIE_MT_SaveOpen(Menu):
# 9 - TOP - RIGHT
pie.operator("wm.save_as_mainfile", text="Save As...", icon='NONE')
# 1 - BOTTOM - LEFT
pie.operator("file.save_incremental", text="Incremental Save", icon='NONE')
if bpy.data.is_saved:
default_operator_contest = layout.operator_context
layout.operator_context = 'EXEC_DEFAULT'
pie.operator(
"wm.save_as_mainfile", text="Incremental Save", icon='NONE',
).filepath = self._save_as_mainfile_calc_incremental_name()
layout.operator_context = default_operator_contest
else:
pie.box().label(text="Incremental Save (unsaved)")
# 3 - BOTTOM - RIGHT
pie.menu("PIE_MT_recover", text="Recovery Menu", icon='RECOVER_LAST')
@ -109,60 +134,8 @@ class PIE_MT_fileio(Menu):
box.menu("TOPBAR_MT_file_export", icon='EXPORT')
# Save Incremental
class PIE_OT_FileIncrementalSave(Operator):
bl_idname = "file.save_incremental"
bl_label = "Save Incremental"
bl_description = "Save First! then Incremental, .blend will get _001 extension"
bl_options = {"REGISTER"}
@classmethod
def poll(cls, context):
return (bpy.data.filepath != "")
def execute(self, context):
f_path = bpy.data.filepath
b_name = bpy.path.basename(f_path)
if b_name and b_name.find("_") != -1:
# except in cases when there is an underscore in the name like my_file.blend
try:
str_nb = b_name.rpartition("_")[-1].rpartition(".blend")[0]
int_nb = int(str(str_nb))
new_nb = str_nb.replace(str(int_nb), str(int_nb + 1))
output = f_path.replace(str_nb, new_nb)
i = 1
while os.path.isfile(output):
str_nb = b_name.rpartition("_")[-1].rpartition(".blend")[0]
i += 1
new_nb = str_nb.replace(str(int_nb), str(int_nb + i))
output = f_path.replace(str_nb, new_nb)
except ValueError:
output = f_path.rpartition(".blend")[0] + "_001" + ".blend"
else:
# no underscore in the name or saving a nameless (.blend) file
output = f_path.rpartition(".blend")[0] + "_001" + ".blend"
# fix for saving in a directory without privileges
try:
bpy.ops.wm.save_as_mainfile(filepath=output)
except:
self.report({'WARNING'},
"File could not be saved. Check the System Console for errors")
return {'CANCELLED'}
self.report(
{'INFO'}, "File: {0} - Created at: {1}".format(
output[len(bpy.path.abspath("//")):],
output[:len(bpy.path.abspath("//"))]),
)
return {'FINISHED'}
classes = (
PIE_MT_SaveOpen,
PIE_OT_FileIncrementalSave,
PIE_MT_fileio,
PIE_MT_recover,
PIE_MT_link,

View File

@ -274,17 +274,18 @@ def demo_mode_update():
if global_config["anim_screen_switch"]:
# print(time_delta, 1)
if time_delta > global_config["anim_screen_switch"]:
screen = bpy.context.window.screen
index = bpy.data.screens.keys().index(screen.name)
screen_new = bpy.data.screens[(index if index > 0 else len(bpy.data.screens)) - 1]
bpy.context.window.screen = screen_new
window = bpy.context.window
scene = window.scene
workspace = window.workspace
index = bpy.data.workspaces.keys().index(workspace.name)
workspace_new = bpy.data.workspaces[(index + 1) % len(bpy.data.workspaces)]
window.workspace = workspace_new
global_state["last_switch"] = time_current
# if we also switch scenes then reset last frame
# otherwise it could mess up cycle calc.
if screen.scene != screen_new.scene:
# If we also switch scenes then reset last frame
# otherwise it could mess up cycle calculation.
if scene != window.scene:
global_state["last_frame"] = -1
#if global_config["mode"] == 'PLAY':