Add Chromium-only Blender WebEngine parity work
This commit is contained in:
230
blender-5.2.0/scripts/modules/_animsys_refactor.py
Normal file
230
blender-5.2.0/scripts/modules/_animsys_refactor.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This module has utility functions for renaming
|
||||
rna values in fcurves and drivers.
|
||||
|
||||
Currently unused, but might become useful later again.
|
||||
"""
|
||||
__all__ = (
|
||||
"update_data_paths",
|
||||
)
|
||||
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
IS_TESTING = False
|
||||
|
||||
|
||||
def classes_recursive(base_type, clss=None):
|
||||
if clss is None:
|
||||
clss = [base_type]
|
||||
else:
|
||||
clss.append(base_type)
|
||||
|
||||
for base_type_iter in base_type.__bases__:
|
||||
if base_type_iter is not object:
|
||||
classes_recursive(base_type_iter, clss)
|
||||
|
||||
return clss
|
||||
|
||||
|
||||
class DataPathBuilder:
|
||||
"""Dummy class used to parse fcurve and driver data paths."""
|
||||
__slots__ = ("data_path", )
|
||||
|
||||
def __init__(self, attrs):
|
||||
self.data_path = attrs
|
||||
|
||||
def __getattr__(self, attr):
|
||||
str_value = ".{:s}".format(attr)
|
||||
return DataPathBuilder(self.data_path + (str_value, ))
|
||||
|
||||
def __getitem__(self, key):
|
||||
if type(key) is int:
|
||||
str_value = '[{:d}]'.format(key)
|
||||
elif type(key) is str:
|
||||
str_value = '["{:s}"]'.format(bpy.utils.escape_identifier(key))
|
||||
else:
|
||||
raise Exception("unsupported accessor {!r} of type {!r} (internal error)".format(key, type(key)))
|
||||
return DataPathBuilder(self.data_path + (str_value, ))
|
||||
|
||||
def resolve(self, real_base, rna_update_from_map, fcurve, log):
|
||||
"""Return (attribute, value) pairs."""
|
||||
pairs = []
|
||||
base = real_base
|
||||
for item in self.data_path:
|
||||
if base is not Ellipsis:
|
||||
base_new = Ellipsis
|
||||
# find the new name
|
||||
if item.startswith("."):
|
||||
for class_name, item_new, options in (
|
||||
rna_update_from_map.get(item[1:], []) +
|
||||
[(None, item[1:], None)]
|
||||
):
|
||||
if callable(item_new):
|
||||
# No type check here, callback is assumed to know what it's doing.
|
||||
base_new, item_new = item_new(base, class_name, item[1:], fcurve, options)
|
||||
if base_new is not Ellipsis:
|
||||
break # found, don't keep looking
|
||||
else:
|
||||
# Type check!
|
||||
type_ok = True
|
||||
if class_name is not None:
|
||||
type_ok = False
|
||||
for base_type in classes_recursive(type(base)):
|
||||
if base_type.__name__ == class_name:
|
||||
type_ok = True
|
||||
break
|
||||
if type_ok:
|
||||
try:
|
||||
# print("base." + item_new)
|
||||
base_new = eval("base." + item_new)
|
||||
break # found, don't keep looking
|
||||
except Exception:
|
||||
pass
|
||||
item_new = "." + item_new
|
||||
else:
|
||||
item_new = item
|
||||
try:
|
||||
base_new = eval("base" + item_new)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if base_new is Ellipsis:
|
||||
print("Failed to resolve data path:", self.data_path, file=log)
|
||||
base = base_new
|
||||
else:
|
||||
item_new = item
|
||||
|
||||
pairs.append((item_new, base))
|
||||
return pairs
|
||||
|
||||
|
||||
def id_iter():
|
||||
from bpy.types import bpy_prop_collection
|
||||
assert isinstance(bpy.data.objects, bpy_prop_collection)
|
||||
|
||||
for attr in dir(bpy.data):
|
||||
data_iter = getattr(bpy.data, attr, None)
|
||||
if isinstance(data_iter, bpy_prop_collection):
|
||||
for id_data in data_iter:
|
||||
if id_data.library is None:
|
||||
yield id_data
|
||||
|
||||
|
||||
def anim_data_actions(anim_data) -> list[tuple[bpy.types.Action, bpy.types.ActionSlot]]:
|
||||
actions = []
|
||||
actions.append((anim_data.action, anim_data.action_slot))
|
||||
for track in anim_data.nla_tracks:
|
||||
for strip in track.strips:
|
||||
actions.append((strip.action, strip.action_slot))
|
||||
|
||||
# Filter out None actions/slots, because if either is None, there is no animation.
|
||||
return [(act, slot) for (act, slot) in actions if act and slot]
|
||||
|
||||
|
||||
def find_path_new(id_data, data_path, rna_update_from_map, fcurve, log):
|
||||
# note!, id_data can be ID type or a node tree
|
||||
# ignore ID props for now
|
||||
if data_path.startswith("["):
|
||||
return data_path
|
||||
|
||||
# recursive path fixing, likely will be one in most cases.
|
||||
data_path_builder = eval("DataPathBuilder(tuple())." + data_path)
|
||||
data_resolve = data_path_builder.resolve(id_data, rna_update_from_map, fcurve, log)
|
||||
|
||||
path_new = [pair[0] for pair in data_resolve]
|
||||
|
||||
return "".join(path_new)[1:] # skip the first "."
|
||||
|
||||
|
||||
def update_data_paths(rna_update, log=sys.stdout):
|
||||
"""
|
||||
rna_update triple [(class_name, from, to or to_callback, callback options), ...]
|
||||
to_callback is a function with this signature: update_cb(base, class_name, old_path, fcurve, options)
|
||||
where base is current object, class_name is the expected type name of base (callback has to handle
|
||||
this), old_path it the org name of base's property, fcurve is the affected fcurve (!),
|
||||
and options is an opaque data.
|
||||
class_name, fcurve and options may be None!
|
||||
"""
|
||||
from bpy_extras import anim_utils
|
||||
|
||||
rna_update_from_map = {}
|
||||
for ren_class, ren_from, ren_to, options in rna_update:
|
||||
rna_update_from_map.setdefault(ren_from, []).append((ren_class, ren_to, options))
|
||||
|
||||
for id_data in id_iter():
|
||||
anim_data_ls: list[tuple[bpy.types.ID, bpy.types.AnimData | None]] = [
|
||||
(id_data, getattr(id_data, "animation_data", None))]
|
||||
# check node-trees too
|
||||
node_tree = getattr(id_data, "node_tree", None)
|
||||
if node_tree:
|
||||
anim_data_ls.append((node_tree, node_tree.animation_data))
|
||||
|
||||
for anim_data_base, anim_data in anim_data_ls:
|
||||
if anim_data is None:
|
||||
continue
|
||||
|
||||
for fcurve in anim_data.drivers:
|
||||
data_path = fcurve.data_path
|
||||
data_path_new = find_path_new(anim_data_base, data_path, rna_update_from_map, fcurve, log)
|
||||
# print(data_path_new)
|
||||
if data_path_new != data_path:
|
||||
if not IS_TESTING:
|
||||
fcurve.data_path = data_path_new
|
||||
fcurve.driver.is_valid = True # reset to allow this to work again
|
||||
print(
|
||||
"driver-fcurve ({:s}): {:s} -> {:s}".format(id_data.name, data_path, data_path_new),
|
||||
file=log,
|
||||
)
|
||||
|
||||
for var in fcurve.driver.variables:
|
||||
if var.type == 'SINGLE_PROP':
|
||||
for tar in var.targets:
|
||||
id_data_other = tar.id
|
||||
data_path = tar.data_path
|
||||
|
||||
if id_data_other and data_path:
|
||||
data_path_new = find_path_new(id_data_other, data_path, rna_update_from_map, None, log)
|
||||
# print(data_path_new)
|
||||
if data_path_new != data_path:
|
||||
if not IS_TESTING:
|
||||
tar.data_path = data_path_new
|
||||
print(
|
||||
"driver ({:s}): {:s} -> {:s}".format(
|
||||
id_data_other.name,
|
||||
data_path,
|
||||
data_path_new,
|
||||
),
|
||||
file=log,
|
||||
)
|
||||
|
||||
for action, action_slot in anim_data_actions(anim_data):
|
||||
channelbag = anim_utils.action_get_channelbag_for_slot(action, action_slot)
|
||||
if not channelbag:
|
||||
continue
|
||||
for fcu in channelbag.fcurves:
|
||||
data_path = fcu.data_path
|
||||
data_path_new = find_path_new(anim_data_base, data_path, rna_update_from_map, fcu, log)
|
||||
# print(data_path_new)
|
||||
if data_path_new != data_path:
|
||||
if not IS_TESTING:
|
||||
fcu.data_path = data_path_new
|
||||
print("fcurve ({:s}): {:s} -> {:s}".format(id_data.name, data_path, data_path_new), file=log)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
# Example, should be called externally
|
||||
# (class, from, to or to_callback, callback_options)
|
||||
replace_ls = [
|
||||
("AnimVizMotionPaths", "frame_after", "frame_after", None),
|
||||
("AnimVizMotionPaths", "frame_before", "frame_before", None),
|
||||
("AnimVizOnionSkinning", "frame_after", "frame_after", None),
|
||||
]
|
||||
|
||||
update_data_paths(replace_ls)
|
||||
@@ -0,0 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
Utilities relating to text mode console interactions.
|
||||
"""
|
||||
@@ -0,0 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Copyright (c) 2009 https://www.stani.be
|
||||
|
||||
"""Package for console specific modules."""
|
||||
@@ -0,0 +1,174 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Copyright (c) 2009 https://www.stani.be
|
||||
|
||||
import inspect
|
||||
import re
|
||||
|
||||
|
||||
# regular expression constants
|
||||
DEF_DOC = r'{:s}\s*(\(.*?\))'
|
||||
DEF_SOURCE = r'def\s+{:s}\s*(\(.*?\)):'
|
||||
RE_EMPTY_LINE = re.compile(r'^\s*\n')
|
||||
RE_FLAG = re.MULTILINE | re.DOTALL
|
||||
RE_NEWLINE = re.compile('\n+')
|
||||
RE_SPACE = re.compile(r'\s+')
|
||||
RE_DEF_COMPLETE = re.compile(
|
||||
# don't start with a quote
|
||||
'''(?:^|[^"'a-zA-Z0-9_])'''
|
||||
# start with a \w = [a-zA-Z0-9_]
|
||||
r'''((\w+'''
|
||||
# allow also dots and closed bracket pairs []
|
||||
r'''(?:\w|[.]|\[.+?\])*'''
|
||||
# allow empty string
|
||||
'''|)'''
|
||||
# allow opening bracket(s)
|
||||
r'''(?:\(|\s)*)$''')
|
||||
|
||||
|
||||
def reduce_newlines(text):
|
||||
"""Reduces multiple newlines to a single newline.
|
||||
|
||||
:param text: text with multiple newlines
|
||||
:type text: str
|
||||
:returns: text with single newlines
|
||||
:rtype: str
|
||||
|
||||
>>> reduce_newlines('hello\\n\\nworld')
|
||||
'hello\\nworld'
|
||||
"""
|
||||
return RE_NEWLINE.sub('\n', text)
|
||||
|
||||
|
||||
def reduce_spaces(text):
|
||||
"""Reduces multiple white-spaces to a single space.
|
||||
|
||||
:param text: text with multiple spaces
|
||||
:type text: str
|
||||
:returns: text with single spaces
|
||||
:rtype: str
|
||||
|
||||
>>> reduce_spaces('hello \\nworld')
|
||||
'hello world'
|
||||
"""
|
||||
return RE_SPACE.sub(' ', text)
|
||||
|
||||
|
||||
def get_doc(obj):
|
||||
"""Get the doc string or comments for an object.
|
||||
|
||||
:param object: object
|
||||
:returns: doc string
|
||||
:rtype: str
|
||||
|
||||
>>> get_doc(abs)
|
||||
'abs(number) -> number\\n\\nReturn the absolute value of the argument.'
|
||||
"""
|
||||
result = inspect.getdoc(obj) or inspect.getcomments(obj)
|
||||
return result and RE_EMPTY_LINE.sub('', result.rstrip()) or ''
|
||||
|
||||
|
||||
def get_argspec(func, *, strip_self=True, doc=None, source=None):
|
||||
"""Get argument specifications.
|
||||
|
||||
:param strip_self: strip ``self`` from argspec
|
||||
:type strip_self: bool
|
||||
:param doc: doc string of func (optional)
|
||||
:type doc: str
|
||||
:param source: source code of func (optional)
|
||||
:type source: str
|
||||
:returns: argument specification
|
||||
:rtype: str
|
||||
|
||||
>>> get_argspec(inspect.getclasstree)
|
||||
'(classes, unique=0)'
|
||||
>>> get_argspec(abs)
|
||||
'(number)'
|
||||
"""
|
||||
# get the function object of the class
|
||||
try:
|
||||
func = func.__func__
|
||||
except AttributeError:
|
||||
pass
|
||||
# is callable?
|
||||
if not hasattr(func, '__call__'):
|
||||
return ''
|
||||
# func should have a name
|
||||
try:
|
||||
func_name = func.__name__
|
||||
except AttributeError:
|
||||
return ''
|
||||
# From docstring.
|
||||
if doc is None:
|
||||
doc = get_doc(func)
|
||||
match = re.search(DEF_DOC.format(func_name), doc, RE_FLAG)
|
||||
# from source code
|
||||
if not match:
|
||||
if source is None:
|
||||
try:
|
||||
source = inspect.getsource(func)
|
||||
except (TypeError, IOError):
|
||||
source = ''
|
||||
if source:
|
||||
match = re.search(DEF_SOURCE.format(func_name), source, RE_FLAG)
|
||||
if match:
|
||||
argspec = reduce_spaces(match.group(1))
|
||||
else:
|
||||
# try with the inspect.getarg* functions
|
||||
try:
|
||||
argspec = inspect.formatargspec(*inspect.getfullargspec(func))
|
||||
except:
|
||||
try:
|
||||
argspec = inspect.formatargvalues(
|
||||
*inspect.getargvalues(func))
|
||||
except:
|
||||
argspec = ''
|
||||
if strip_self:
|
||||
argspec = argspec.replace('self, ', '')
|
||||
return argspec
|
||||
|
||||
|
||||
def complete(line, cursor, namespace):
|
||||
"""Complete callable with call-tip.
|
||||
|
||||
:param line: incomplete text line
|
||||
:type line: str
|
||||
:param cursor: current character position
|
||||
:type cursor: int
|
||||
:param namespace: namespace
|
||||
:type namespace: dict[str, Any]
|
||||
:returns: (matches, world, scrollback)
|
||||
:rtype: tuple[str, str, str]
|
||||
|
||||
>>> import os
|
||||
>>> complete('os.path.isdir(', 14, {'os': os})[-1]
|
||||
'isdir(s)\\nReturn true if the pathname refers to an existing directory.'
|
||||
>>> complete('abs(', 4, {})[-1]
|
||||
'abs(number) -> number\\nReturn the absolute value of the argument.'
|
||||
"""
|
||||
matches = []
|
||||
word = ''
|
||||
scrollback = ''
|
||||
match = RE_DEF_COMPLETE.search(line[:cursor])
|
||||
|
||||
if match:
|
||||
word = match.group(1)
|
||||
func_word = match.group(2)
|
||||
try:
|
||||
func = eval(func_word, namespace)
|
||||
except BaseException:
|
||||
func = None
|
||||
|
||||
if func:
|
||||
doc = get_doc(func)
|
||||
argspec = get_argspec(func, doc=doc)
|
||||
scrollback = func_word.split('.')[-1] + (argspec or '()')
|
||||
if doc.startswith(scrollback):
|
||||
scrollback = doc
|
||||
elif doc:
|
||||
scrollback += '\n' + doc
|
||||
scrollback = reduce_newlines(scrollback)
|
||||
|
||||
return matches, word, scrollback
|
||||
@@ -0,0 +1,180 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Copyright (c) 2009 Fernando Perez, https://www.stani.be
|
||||
|
||||
# Original copyright (see docstring):
|
||||
# ****************************************************************************
|
||||
# Copyright (C) 2001-2006 Fernando Perez <fperez@colorado.edu>
|
||||
#
|
||||
# Distributed under the terms of the BSD License. The full license is in
|
||||
# the file COPYING, distributed as part of this software.
|
||||
# ****************************************************************************
|
||||
|
||||
"""Completer for import statements
|
||||
|
||||
Original code was from IPython/Extensions/ipy_completers.py. The following
|
||||
changes have been made:
|
||||
- ported to python3
|
||||
- pep8 polishing
|
||||
- limit list of modules to prefix in case of "from w"
|
||||
- sorted modules
|
||||
- added sphinx documentation
|
||||
- complete() returns a blank list of the module isn't found
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
TIMEOUT_STORAGE = 3 # Time in secs after which the root-modules will be stored
|
||||
TIMEOUT_GIVEUP = 20 # Time in secs after which we give up
|
||||
|
||||
ROOT_MODULES = None
|
||||
|
||||
|
||||
def get_root_modules():
|
||||
"""
|
||||
Returns a list containing the names of all the modules available in the
|
||||
folders of the python-path.
|
||||
|
||||
:returns: modules
|
||||
:rtype: list[ModuleType]
|
||||
"""
|
||||
global ROOT_MODULES
|
||||
modules = []
|
||||
if not (ROOT_MODULES is None):
|
||||
return ROOT_MODULES
|
||||
from time import time
|
||||
t = time()
|
||||
store = False
|
||||
for path in sys.path:
|
||||
modules += module_list(path)
|
||||
if time() - t >= TIMEOUT_STORAGE and not store:
|
||||
# Caching the list of root modules, please wait!
|
||||
store = True
|
||||
if time() - t > TIMEOUT_GIVEUP:
|
||||
# This is taking too long, we give up.
|
||||
ROOT_MODULES = []
|
||||
return []
|
||||
|
||||
modules += sys.builtin_module_names
|
||||
|
||||
# needed for modules defined in C
|
||||
modules += sys.modules.keys()
|
||||
|
||||
modules = set(modules)
|
||||
modules.discard("__init__")
|
||||
modules = sorted(list(modules))
|
||||
if store:
|
||||
ROOT_MODULES = modules
|
||||
return modules
|
||||
|
||||
|
||||
def module_list(path):
|
||||
"""
|
||||
Return the list containing the names of the modules available in
|
||||
the given folder.
|
||||
|
||||
:param path: folder path
|
||||
:type path: str
|
||||
:returns: modules
|
||||
:rtype: list[ModuleType]
|
||||
"""
|
||||
|
||||
if os.path.isdir(path):
|
||||
folder_list = os.listdir(path)
|
||||
elif path.endswith('.egg'):
|
||||
from zipimport import zipimporter
|
||||
try:
|
||||
folder_list = [f for f in zipimporter(path)._files]
|
||||
except:
|
||||
folder_list = []
|
||||
else:
|
||||
folder_list = []
|
||||
# folder_list = glob.glob(os.path.join(path,'*'))
|
||||
folder_list = [
|
||||
p for p in folder_list
|
||||
if (os.path.exists(os.path.join(path, p, '__init__.py')) or
|
||||
p[-3:] in {'.py', '.so'} or
|
||||
p[-4:] in {'.pyc', '.pyo', '.pyd'})]
|
||||
|
||||
folder_list = [os.path.basename(p).split('.')[0] for p in folder_list]
|
||||
return folder_list
|
||||
|
||||
|
||||
def complete(line):
|
||||
"""
|
||||
Returns a list containing the completion possibilities for an import line.
|
||||
|
||||
:param line:
|
||||
|
||||
incomplete line which contains an import statement::
|
||||
|
||||
import xml.d
|
||||
from xml.dom import
|
||||
|
||||
:type line: str
|
||||
:returns: list of completion possibilities
|
||||
:rtype: list[str]
|
||||
|
||||
>>> complete('import weak')
|
||||
['weakref']
|
||||
>>> complete('from weakref import C')
|
||||
['CallableProxyType']
|
||||
"""
|
||||
import inspect
|
||||
|
||||
def try_import(mod, *, only_modules=False):
|
||||
|
||||
def is_importable(module, attr):
|
||||
if only_modules:
|
||||
return inspect.ismodule(getattr(module, attr))
|
||||
else:
|
||||
return not (attr[:2] == '__' and attr[-2:] == '__')
|
||||
|
||||
try:
|
||||
m = __import__(mod)
|
||||
except:
|
||||
return []
|
||||
mods = mod.split('.')
|
||||
for module in mods[1:]:
|
||||
m = getattr(m, module)
|
||||
if (not hasattr(m, '__file__')) or (not only_modules) or\
|
||||
(hasattr(m, '__file__') and '__init__' in m.__file__):
|
||||
completion_list = [attr for attr in dir(m)
|
||||
if is_importable(m, attr)]
|
||||
else:
|
||||
completion_list = []
|
||||
completion_list.extend(getattr(m, '__all__', []))
|
||||
if hasattr(m, '__file__') and '__init__' in m.__file__:
|
||||
completion_list.extend(module_list(os.path.dirname(m.__file__)))
|
||||
completion_list = list(set(completion_list))
|
||||
if '__init__' in completion_list:
|
||||
completion_list.remove('__init__')
|
||||
return completion_list
|
||||
|
||||
def filter_prefix(names, prefix):
|
||||
return [name for name in names if name.startswith(prefix)]
|
||||
|
||||
words = line.split(' ')
|
||||
if len(words) == 3 and words[0] == 'from':
|
||||
return ['import ']
|
||||
if len(words) < 3 and (words[0] in {'import', 'from'}):
|
||||
if len(words) == 1:
|
||||
return get_root_modules()
|
||||
mod = words[1].split('.')
|
||||
if len(mod) < 2:
|
||||
return filter_prefix(get_root_modules(), words[-1])
|
||||
completion_list = try_import('.'.join(mod[:-1]), only_modules=True)
|
||||
completion_list = ['.'.join(mod[:-1] + [el]) for el in completion_list]
|
||||
return filter_prefix(completion_list, words[-1])
|
||||
if len(words) >= 3 and words[0] == 'from':
|
||||
mod = words[1]
|
||||
return filter_prefix(try_import(mod), words[-1])
|
||||
|
||||
# get here if the import is not found
|
||||
# import invalid_module
|
||||
# ^, in this case return nothing
|
||||
return []
|
||||
@@ -0,0 +1,192 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Copyright (c) 2009 https://www.stani.be
|
||||
|
||||
"""Autocomplete with the standard library"""
|
||||
|
||||
import re
|
||||
import rlcompleter
|
||||
|
||||
|
||||
RE_INCOMPLETE_INDEX = re.compile(r'(.*?)\[[^\]]+$')
|
||||
|
||||
TEMP = '__tEmP__' # only \w characters are allowed!
|
||||
TEMP_N = len(TEMP)
|
||||
|
||||
|
||||
def is_dict(obj):
|
||||
"""Returns whether obj is a dictionary"""
|
||||
return hasattr(obj, "keys") and hasattr(getattr(obj, "keys"), "__call__")
|
||||
|
||||
|
||||
def is_struct_seq(obj):
|
||||
"""Returns whether obj is a structured sequence subclass: sys.float_info"""
|
||||
return isinstance(obj, tuple) and hasattr(obj, "n_fields")
|
||||
|
||||
|
||||
def complete_names(word, namespace):
|
||||
"""Complete variable names or attributes
|
||||
|
||||
:param word: word to be completed
|
||||
:type word: str
|
||||
:param namespace: namespace
|
||||
:type namespace: dict[str, Any]
|
||||
:returns: completion matches
|
||||
:rtype: list of str
|
||||
|
||||
>>> complete_names('fo', {'foo': 'bar'})
|
||||
['foo', 'for', 'format(']
|
||||
"""
|
||||
# start completer
|
||||
completer = rlcompleter.Completer(namespace)
|
||||
# find matches with std library (don't try to implement this yourself)
|
||||
completer.complete(word, 0)
|
||||
return sorted(set(completer.matches))
|
||||
|
||||
|
||||
def complete_indices(word, namespace, *, obj=None, base=None):
|
||||
"""Complete a list or dictionary with its indices:
|
||||
|
||||
* integer numbers for list
|
||||
* any keys for dictionary
|
||||
|
||||
:param word: word to be completed
|
||||
:type word: str
|
||||
:param namespace: namespace
|
||||
:type namespace: dict
|
||||
:param obj: object evaluated from base
|
||||
:param base: sub-string which can be evaluated into an object.
|
||||
:type base: str
|
||||
:returns: completion matches
|
||||
:rtype: list of str
|
||||
|
||||
>>> complete_indices('foo', {'foo': range(5)})
|
||||
['foo[0]', 'foo[1]', 'foo[2]', 'foo[3]', 'foo[4]']
|
||||
>>> complete_indices('foo', {'foo': {'bar':0, 1:2}})
|
||||
['foo[1]', "foo['bar']"]
|
||||
>>> complete_indices("foo['b", {'foo': {'bar':0, 1:2}}, base='foo')
|
||||
["foo['bar']"]
|
||||
"""
|
||||
# FIXME: 'foo["b'
|
||||
if base is None:
|
||||
base = word
|
||||
if obj is None:
|
||||
try:
|
||||
obj = eval(base, namespace)
|
||||
except BaseException:
|
||||
return []
|
||||
if not hasattr(obj, '__getitem__'):
|
||||
# obj is not a list or dictionary
|
||||
return []
|
||||
|
||||
obj_is_dict = is_dict(obj)
|
||||
|
||||
# rare objects have a __getitem__ but no __len__ (eg. BMEdge)
|
||||
if not obj_is_dict:
|
||||
try:
|
||||
obj_len = len(obj)
|
||||
except TypeError:
|
||||
return []
|
||||
|
||||
if obj_is_dict:
|
||||
# dictionary type
|
||||
matches = ['{:s}[{!r}]'.format(base, key) for key in sorted(obj.keys())]
|
||||
else:
|
||||
# list type
|
||||
matches = ['{:s}[{:d}]'.format(base, idx) for idx in range(obj_len)]
|
||||
if word != base:
|
||||
matches = [match for match in matches if match.startswith(word)]
|
||||
return matches
|
||||
|
||||
|
||||
def complete(word, namespace, *, private=True):
|
||||
"""Complete word within a namespace with the standard rlcompleter
|
||||
module. Also supports index or key access [].
|
||||
|
||||
:param word: word to be completed
|
||||
:type word: str
|
||||
:param namespace: namespace
|
||||
:type namespace: dict
|
||||
:param private: whether private attribute/methods should be returned
|
||||
:type private: bool
|
||||
:returns: completion matches
|
||||
:rtype: list of str
|
||||
|
||||
>>> complete('foo[1', {'foo': range(14)})
|
||||
['foo[1]', 'foo[10]', 'foo[11]', 'foo[12]', 'foo[13]']
|
||||
>>> complete('foo[0]', {'foo': [range(5)]})
|
||||
['foo[0][0]', 'foo[0][1]', 'foo[0][2]', 'foo[0][3]', 'foo[0][4]']
|
||||
>>> complete('foo[0].i', {'foo': [range(5)]})
|
||||
['foo[0].index(', 'foo[0].insert(']
|
||||
>>> complete('rlcompleter', {'rlcompleter': rlcompleter})
|
||||
['rlcompleter.']
|
||||
"""
|
||||
#
|
||||
# if word is empty -> nothing to complete
|
||||
if not word:
|
||||
return []
|
||||
|
||||
re_incomplete_index = RE_INCOMPLETE_INDEX.search(word)
|
||||
if re_incomplete_index:
|
||||
# ignore incomplete index at the end, e.g 'a[1' -> 'a'
|
||||
matches = complete_indices(word, namespace,
|
||||
base=re_incomplete_index.group(1))
|
||||
|
||||
elif not ('[' in word):
|
||||
matches = complete_names(word, namespace)
|
||||
|
||||
elif word[-1] == ']':
|
||||
matches = [word]
|
||||
|
||||
elif '.' in word:
|
||||
# brackets are normally not allowed -> work around
|
||||
|
||||
# remove brackets by using a temp var without brackets
|
||||
obj, attr = word.rsplit('.', 1)
|
||||
try:
|
||||
# do not run the obj expression in the console
|
||||
namespace[TEMP] = eval(obj, namespace)
|
||||
except BaseException:
|
||||
return []
|
||||
matches = complete_names(TEMP + '.' + attr, namespace)
|
||||
matches = [obj + match[TEMP_N:] for match in matches]
|
||||
del namespace[TEMP]
|
||||
|
||||
else:
|
||||
# safety net, but when would this occur?
|
||||
return []
|
||||
|
||||
if not matches:
|
||||
return []
|
||||
|
||||
# add '.', '(' or '[' if no match has been found
|
||||
elif len(matches) == 1 and matches[0] == word:
|
||||
|
||||
# try to retrieve the object
|
||||
try:
|
||||
obj = eval(word, namespace)
|
||||
except BaseException:
|
||||
return []
|
||||
# ignore basic types
|
||||
if type(obj) in {bool, float, int, str}:
|
||||
return []
|
||||
# an extra char '[', '(' or '.' will be added
|
||||
if hasattr(obj, '__getitem__') and not is_struct_seq(obj):
|
||||
# list or dictionary
|
||||
matches = complete_indices(word, namespace, obj=obj)
|
||||
elif hasattr(obj, '__call__'):
|
||||
# callables
|
||||
matches = [word + '(']
|
||||
else:
|
||||
# any other type
|
||||
matches = [word + '.']
|
||||
|
||||
# separate public from private
|
||||
public_matches = [match for match in matches if not ('._' in match)]
|
||||
if private:
|
||||
private_matches = [match for match in matches if '._' in match]
|
||||
return public_matches + private_matches
|
||||
else:
|
||||
return public_matches
|
||||
@@ -0,0 +1,138 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Copyright (c) 2009 https://www.stani.be
|
||||
|
||||
"""This module provides intellisense features such as:
|
||||
|
||||
* autocompletion
|
||||
* calltips
|
||||
|
||||
It unifies all completion plugins and only loads them on demand.
|
||||
"""
|
||||
|
||||
# TODO: file complete if startswith quotes
|
||||
import os
|
||||
import re
|
||||
|
||||
# regular expressions to find out which completer we need
|
||||
|
||||
# line which starts with an import statement
|
||||
RE_MODULE = re.compile(r'''^import(\s|$)|from.+''')
|
||||
|
||||
# The following regular expression means an 'unquoted' word
|
||||
RE_UNQUOTED_WORD = re.compile(
|
||||
# don't start with a quote
|
||||
r'''(?:^|[^"'a-zA-Z0-9_])'''
|
||||
# start with a \w = [a-zA-Z0-9_]
|
||||
r'''((?:\w+'''
|
||||
# allow also dots and closed bracket pairs []
|
||||
r'''(?:\w|[.]|\[.+?\])*'''
|
||||
# allow empty string
|
||||
r'''|)'''
|
||||
# allow an unfinished index at the end (including quotes)
|
||||
r'''(?:\[[^\]]*$)?)$''',
|
||||
# allow unicode as theoretically this is possible
|
||||
re.UNICODE)
|
||||
|
||||
|
||||
def complete(line, cursor, namespace, private):
|
||||
"""Returns a list of possible completions:
|
||||
|
||||
* name completion
|
||||
* attribute completion (obj.attr)
|
||||
* index completion for lists and dictionaries
|
||||
* module completion (from/import)
|
||||
|
||||
:param line: incomplete text line
|
||||
:type line: str
|
||||
:param cursor: current character position
|
||||
:type cursor: int
|
||||
:param namespace: namespace
|
||||
:type namespace: dict
|
||||
:param private: whether private variables should be listed
|
||||
:type private: bool
|
||||
:returns: list of completions, word
|
||||
:rtype: tuple[list[str], str]
|
||||
|
||||
>>> complete('re.sr', 5, {'re': re})
|
||||
(['re.sre_compile', 're.sre_parse'], 're.sr')
|
||||
"""
|
||||
re_unquoted_word = RE_UNQUOTED_WORD.search(line[:cursor])
|
||||
if re_unquoted_word:
|
||||
# unquoted word -> module or attribute completion
|
||||
word = re_unquoted_word.group(1)
|
||||
if RE_MODULE.match(line):
|
||||
from . import complete_import
|
||||
matches = complete_import.complete(line)
|
||||
if not private:
|
||||
matches[:] = [m for m in matches if m[:1] != "_"]
|
||||
matches.sort()
|
||||
else:
|
||||
from . import complete_namespace
|
||||
matches = complete_namespace.complete(word, namespace, private=private)
|
||||
else:
|
||||
# for now we don't have completers for strings
|
||||
# TODO: add file auto completer for strings
|
||||
word = ''
|
||||
matches = []
|
||||
return matches, word
|
||||
|
||||
|
||||
def expand(line, cursor, namespace, *, private=True):
|
||||
"""This method is invoked when the user asks auto-completion,
|
||||
e.g. when Ctrl+Space is clicked.
|
||||
|
||||
:param line: incomplete text line
|
||||
:type line: str
|
||||
:param cursor: current character position
|
||||
:type cursor: int
|
||||
:param namespace: namespace
|
||||
:type namespace: dict[str, Any]
|
||||
:param private: whether private variables should be listed
|
||||
:type private: bool
|
||||
:returns:
|
||||
|
||||
current expanded line, updated cursor position and scrollback
|
||||
|
||||
:rtype: str, int, str
|
||||
|
||||
>>> expand('os.path.isdir(', 14, {'os': os})[-1]
|
||||
'isdir(s)\\nReturn true if the pathname refers to an existing directory.'
|
||||
>>> expand('abs(', 4, {})[-1]
|
||||
'abs(number) -> number\\nReturn the absolute value of the argument.'
|
||||
"""
|
||||
if line[:cursor].strip().endswith('('):
|
||||
from . import complete_calltip
|
||||
matches, word, scrollback = complete_calltip.complete(
|
||||
line, cursor, namespace)
|
||||
prefix = os.path.commonprefix(matches)[len(word):]
|
||||
no_calltip = False
|
||||
else:
|
||||
matches, word = complete(line, cursor, namespace, private)
|
||||
prefix = os.path.commonprefix(matches)[len(word):]
|
||||
if len(matches) == 1:
|
||||
scrollback = ''
|
||||
else:
|
||||
# causes blender bug #27495 since string keys may contain '.'
|
||||
# scrollback = ' '.join([m.split('.')[-1] for m in matches])
|
||||
|
||||
# add white space to align with the cursor
|
||||
white_space = " " + (" " * (cursor + len(prefix)))
|
||||
word_prefix = word + prefix
|
||||
scrollback = '\n'.join(
|
||||
[white_space + m[len(word_prefix):]
|
||||
if (word_prefix and m.startswith(word_prefix))
|
||||
else
|
||||
white_space + m.rsplit('.', 1)[-1]
|
||||
for m in matches])
|
||||
|
||||
no_calltip = True
|
||||
|
||||
if prefix:
|
||||
line = line[:cursor] + prefix + line[cursor:]
|
||||
cursor += len(prefix.encode('utf-8'))
|
||||
if no_calltip and prefix.endswith('('):
|
||||
return expand(line, cursor, namespace, private=private)
|
||||
return line, cursor, scrollback
|
||||
5
blender-5.2.0/scripts/modules/_bl_i18n_utils/__init__.py
Normal file
5
blender-5.2.0/scripts/modules/_bl_i18n_utils/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""Package for translation (i18n) tools."""
|
||||
1392
blender-5.2.0/scripts/modules/_bl_i18n_utils/bl_extract_messages.py
Normal file
1392
blender-5.2.0/scripts/modules/_bl_i18n_utils/bl_extract_messages.py
Normal file
File diff suppressed because it is too large
Load Diff
132
blender-5.2.0/scripts/modules/_bl_i18n_utils/merge_po.py
Executable file
132
blender-5.2.0/scripts/modules/_bl_i18n_utils/merge_po.py
Executable file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2012-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Merge one or more .po files into the first dest one.
|
||||
# If a msgkey is present in more than one merged po, the one in the first file wins, unless
|
||||
# it’s marked as fuzzy and one later is not.
|
||||
# The fuzzy flag is removed if necessary.
|
||||
# All other comments are never modified.
|
||||
# However, commented messages in dst will always remain commented, and commented messages are
|
||||
# never merged from sources.
|
||||
|
||||
import sys
|
||||
|
||||
if __package__ is None:
|
||||
import settings
|
||||
import utils
|
||||
else:
|
||||
from . import (
|
||||
settings,
|
||||
utils,
|
||||
)
|
||||
|
||||
|
||||
# XXX This is a quick hack to make it work with new I18n... objects! To be reworked!
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Merge one or more .po files into the first dest one.\n"
|
||||
"If a msgkey (msgctxt, msgid) is present in more than one merged po, the one in the first file "
|
||||
"wins, unless it’s marked as fuzzy and one later is not.\n"
|
||||
"The fuzzy flag is removed if necessary.\n"
|
||||
"All other comments are never modified.\n"
|
||||
"Commented messages in dst will always remain commented, and commented messages are never merged "
|
||||
"from sources."
|
||||
),
|
||||
)
|
||||
parser.add_argument('-s', '--stats', action="store_true", help="Show statistics info.")
|
||||
parser.add_argument('-r', '--replace', action="store_true",
|
||||
help="Replace existing messages of same \"level\" already in dest po.")
|
||||
parser.add_argument('dst', metavar='dst.po', help="The dest po into which merge the others.")
|
||||
parser.add_argument('src', metavar='src.po', nargs='+', help="The po's to merge into the dst.po one.")
|
||||
args = parser.parse_args()
|
||||
|
||||
ret = 0
|
||||
done_msgkeys = set()
|
||||
done_fuzzy_msgkeys = set()
|
||||
nbr_merged = 0
|
||||
nbr_replaced = 0
|
||||
nbr_added = 0
|
||||
nbr_unfuzzied = 0
|
||||
|
||||
dst_msgs = utils.I18nMessages(kind='PO', src=args.dst)
|
||||
if dst_msgs.parsing_errors:
|
||||
print("Dest po is BROKEN, aborting.")
|
||||
return 1
|
||||
if args.stats:
|
||||
print("Dest po, before merging:")
|
||||
dst_msgs.print_stats(prefix="\t")
|
||||
# If we don’t want to replace existing valid translations, pre-populate done_msgkeys and done_fuzzy_msgkeys.
|
||||
if not args.replace:
|
||||
done_msgkeys = dst_msgs.trans_msgs.copy()
|
||||
done_fuzzy_msgkeys = dst_msgs.fuzzy_msgs.copy()
|
||||
for po in args.src:
|
||||
msgs = utils.I18nMessages(kind='PO', src=po)
|
||||
if msgs.parsing_errors:
|
||||
print("\tSrc po {} is BROKEN, skipping.".format(po))
|
||||
ret = 1
|
||||
continue
|
||||
print("\tMerging {}...".format(po))
|
||||
if args.stats:
|
||||
print("\t\tMerged po stats:")
|
||||
msgs.print_stats(prefix="\t\t\t")
|
||||
for msgkey, msg in msgs.msgs.items():
|
||||
msgctxt, msgid = msgkey
|
||||
# This msgkey has already been completely merged, or is a commented one,
|
||||
# or the new message is commented, skip it.
|
||||
if msgkey in (done_msgkeys | dst_msgs.comm_msgs | msgs.comm_msgs):
|
||||
continue
|
||||
is_ttip = msg.is_tooltip
|
||||
# New messages does not yet exists in dest.
|
||||
if msgkey not in dst_msgs.msgs:
|
||||
dst_msgs[msgkey] = msgs.msgs[msgkey]
|
||||
if msgkey in msgs.fuzzy_msgs:
|
||||
done_fuzzy_msgkeys.add(msgkey)
|
||||
dst_msgs.fuzzy_msgs.add(msgkey)
|
||||
elif msgkey in msgs.trans_msgs:
|
||||
done_msgkeys.add(msgkey)
|
||||
dst_msgs.trans_msgs.add(msgkey)
|
||||
nbr_added += 1
|
||||
# From now on, the new messages is already in dst.
|
||||
# New message is neither translated nor fuzzy, skip it.
|
||||
elif msgkey not in (msgs.trans_msgs | msgs.fuzzy_msgs):
|
||||
continue
|
||||
# From now on, the new message is either translated or fuzzy!
|
||||
# The new message is translated.
|
||||
elif msgkey in msgs.trans_msgs:
|
||||
dst_msgs.msgs[msgkey].msgstr = msg.msgstr
|
||||
done_msgkeys.add(msgkey)
|
||||
done_fuzzy_msgkeys.discard(msgkey)
|
||||
if msgkey in dst_msgs.fuzzy_msgs:
|
||||
dst_msgs.fuzzy_msgs.remove(msgkey)
|
||||
nbr_unfuzzied += 1
|
||||
if msgkey not in dst_msgs.trans_msgs:
|
||||
dst_msgs.trans_msgs.add(msgkey)
|
||||
else:
|
||||
nbr_replaced += 1
|
||||
nbr_merged += 1
|
||||
# The new message is fuzzy, org one is fuzzy too, and this msgkey has not yet been merged.
|
||||
elif msgkey not in (dst_msgs.trans_msgs | done_fuzzy_msgkeys):
|
||||
dst_msgs[msgkey].msgstr = msg.msgstr
|
||||
done_fuzzy_msgkeys.add(msgkey)
|
||||
dst_msgs.fuzzy_msgs.add(msgkey)
|
||||
nbr_merged += 1
|
||||
nbr_replaced += 1
|
||||
|
||||
dst_msgs.write(kind='PO', dest=args.dst)
|
||||
|
||||
print("Merged completed. {} messages were merged (among which {} were replaced), {} were added, "
|
||||
"{} were \"un-fuzzied\".".format(nbr_merged, nbr_replaced, nbr_added, nbr_unfuzzied))
|
||||
if args.stats:
|
||||
dst_msgs.update_info()
|
||||
print("Final merged po stats:")
|
||||
dst_msgs.print_stats(prefix="\t")
|
||||
return ret
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n\n *** Running {} *** \n".format(__file__))
|
||||
sys.exit(main())
|
||||
824
blender-5.2.0/scripts/modules/_bl_i18n_utils/settings.py
Normal file
824
blender-5.2.0/scripts/modules/_bl_i18n_utils/settings.py
Normal file
@@ -0,0 +1,824 @@
|
||||
# SPDX-FileCopyrightText: 2012-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Global settings used by all scripts in this directory.
|
||||
# XXX Before any use of the tools in this directory, please make a copy of this file
|
||||
# named "setting.py"
|
||||
# XXX This is a template, most values should be OK, but some you’ll have to
|
||||
# edit (most probably, BLENDER_EXEC and SOURCE_DIR).
|
||||
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
|
||||
# Only do soft-dependency on `bpy` module, not real strong need for it currently.
|
||||
try:
|
||||
import bpy
|
||||
except ModuleNotFoundError:
|
||||
bpy = None
|
||||
|
||||
###############################################################################
|
||||
# MISC
|
||||
###############################################################################
|
||||
|
||||
# The languages defined in Blender.
|
||||
LANGUAGES = (
|
||||
# ID, UI English label, ISO code.
|
||||
(0, "Automatic", "DEFAULT"),
|
||||
(1, "English (US)", "en_US"),
|
||||
(2, "Japanese - 日本語", "ja_JP"),
|
||||
(3, "Dutch - Nederlands", "nl_NL"),
|
||||
(4, "Italian - Italiano", "it_IT"),
|
||||
(5, "German - Deutsch", "de_DE"),
|
||||
(6, "Finnish - Suomi", "fi_FI"),
|
||||
(7, "Swedish - Svenska", "sv_SE"),
|
||||
(8, "French - Français", "fr_FR"),
|
||||
(9, "Spanish - Español", "es"),
|
||||
(10, "Catalan - Català", "ca_AD"),
|
||||
(11, "Czech - Čeština", "cs_CZ"),
|
||||
(12, "Portuguese (Portugal) - Português europeu", "pt_PT"),
|
||||
(13, "Chinese (Simplified) - 简体中文", "zh_HANS"),
|
||||
(14, "Chinese (Traditional) - 繁體中文", "zh_HANT"),
|
||||
(15, "Russian - Русский", "ru_RU"),
|
||||
(16, "Croatian - Hrvatski", "hr"),
|
||||
(17, "Serbian (Cyrillic) - Српски", "sr_RS"),
|
||||
(18, "Ukrainian - Українська", "uk_UA"),
|
||||
(19, "Polish - Polski", "pl_PL"),
|
||||
(20, "Romanian - Român", "ro_RO"),
|
||||
# Using the utf8 flipped form of Arabic (العربية).
|
||||
(21, "Arabic - ﺔﻴﺑﺮﻌﻟﺍ", "ar_EG"),
|
||||
(22, "Bulgarian - Български", "bg_BG"),
|
||||
(23, "Greek - Ελληνικά", "el_GR"),
|
||||
(24, "Korean - 한국어", "ko_KR"),
|
||||
# 25 is free, used to be "Nepali - नेपाली", ("ne_NP").
|
||||
# Using the utf8 flipped form of Persian (فارسی).
|
||||
(26, "Persian - ﯽﺳﺭﺎﻓ", "fa_IR"),
|
||||
(27, "Indonesian - Bahasa indonesia", "id_ID"),
|
||||
(28, "Serbian (Latin) - Srpski latinica", "sr_RS@latin"),
|
||||
(29, "Kyrgyz - Кыргыз тили", "ky_KG"),
|
||||
(30, "Turkish - Türkçe", "tr_TR"),
|
||||
(31, "Hungarian - Magyar", "hu_HU"),
|
||||
(32, "Portuguese (Brazil) - Português brasileiro", "pt_BR"),
|
||||
# Using the utf8 flipped form of Hebrew (עִבְרִית)).
|
||||
(33, "Hebrew - תירִבְעִ", "he_IL"),
|
||||
# 34 is free, used to be "Estonian - Eesti keel" ("et_EE").
|
||||
(35, "Esperanto - Esperanto", "eo"),
|
||||
# 36 is free, used to be "Spanish from Spain" ("es_ES").
|
||||
# 37 is free, used to be "Amharic - አማርኛ" ("am_ET").
|
||||
# 38 is free, used to be "Uzbek (Latin) - Oʻzbek" ("uz_UZ@latin").
|
||||
# 39 is free, used to be "Uzbek (Cyrillic) - Ўзбек" ("uz_UZ@cyrillic").
|
||||
(40, "Hindi - हिन्दी", "hi_IN"),
|
||||
(41, "Vietnamese - Tiếng Việt", "vi_VN"),
|
||||
(42, "Basque - Euskara", "eu_EU"),
|
||||
# 43 is free, used to be "Hausa - Hausa" ("ha").
|
||||
# 44 is free, used to be "Kazakh - Қазақша" ("kk_KZ").
|
||||
(45, "Abkhaz - Аԥсуа бызшәа", "ab"),
|
||||
(46, "Thai - ภาษาไทย", "th_TH"),
|
||||
(47, "Slovak - Slovenčina", "sk_SK"),
|
||||
(48, "Georgian - ქართული", "ka"),
|
||||
(49, "Tamil - தமிழ்", "ta"),
|
||||
# 50 is free, used to be "Khmer - ខ្មែរ" ("km").
|
||||
(51, "Swahili - Kiswahili", "sw"),
|
||||
(52, "Belarusian - Беларуская", "be"),
|
||||
(53, "Danish - Dansk", "da"),
|
||||
(54, "Slovenian - Slovenščina", "sl"),
|
||||
# Using the utf8 flipped form of Urdu (اُردُو).
|
||||
(55, "Urdu - وُدرُا", "ur"),
|
||||
(56, "Lithuanian - Lietuviškai", "lt"),
|
||||
(57, "English (UK)", "en_GB"),
|
||||
(58, "Malayalam - മലയാളം", "ml"),
|
||||
(59, "Norwegian (Bokmål) - Norsk bokmål", "nb"),
|
||||
)
|
||||
|
||||
# Default context, in py (keep in sync with `BLT_translation.hh`)!
|
||||
if bpy is not None:
|
||||
assert bpy.app.translations.contexts.default == "*"
|
||||
DEFAULT_CONTEXT = "*"
|
||||
|
||||
# Name of language file used by Blender to generate translations' menu.
|
||||
LANGUAGES_FILE = "languages"
|
||||
|
||||
# The minimum level of completeness for a `.po` file to be imported from
|
||||
# the working repository to the Blender one, as a percentage.
|
||||
IMPORT_MIN_LEVEL = 0.0
|
||||
|
||||
# Languages in the working repository that should not be imported in the Blender one currently...
|
||||
IMPORT_LANGUAGES_SKIP = set()
|
||||
|
||||
# Languages that need RTL pre-processing.
|
||||
IMPORT_LANGUAGES_RTL = {
|
||||
'ar_EG', 'fa_IR', 'he_IL', 'ur',
|
||||
}
|
||||
|
||||
# The comment prefix used in generated `messages.txt` file.
|
||||
MSG_COMMENT_PREFIX = "#~ "
|
||||
|
||||
# The comment prefix used in generated `messages.txt` file.
|
||||
MSG_CONTEXT_PREFIX = "MSGCTXT:"
|
||||
|
||||
# The default comment prefix used in po's.
|
||||
PO_COMMENT_PREFIX = "# "
|
||||
|
||||
# The comment prefix used to mark sources of msgids, in po's.
|
||||
PO_COMMENT_PREFIX_SOURCE = "#: "
|
||||
|
||||
# The comment prefix used to mark sources of msgids, in po's.
|
||||
PO_COMMENT_PREFIX_SOURCE_CUSTOM = "#. :src: "
|
||||
|
||||
# The general "generated" comment prefix, in po's.
|
||||
PO_COMMENT_PREFIX_GENERATED = "#. "
|
||||
|
||||
# The comment prefix used to comment entries in po's.
|
||||
PO_COMMENT_PREFIX_MSG = "#~ "
|
||||
|
||||
# The comment prefix used to mark fuzzy msgids, in po's.
|
||||
PO_COMMENT_FUZZY = "#, fuzzy"
|
||||
|
||||
# The prefix used to define context, in po's.
|
||||
PO_MSGCTXT = "msgctxt "
|
||||
|
||||
# The prefix used to define msgid, in po's.
|
||||
PO_MSGID = "msgid "
|
||||
|
||||
# The prefix used to define msgstr, in po's.
|
||||
PO_MSGSTR = "msgstr "
|
||||
|
||||
# The 'header' key of po files.
|
||||
PO_HEADER_KEY = (DEFAULT_CONTEXT, "")
|
||||
|
||||
PO_HEADER_MSGSTR = (
|
||||
"Project-Id-Version: {blender_ver} ({blender_hash})\\n\n"
|
||||
"Report-Msgid-Bugs-To: \\n\n"
|
||||
"POT-Creation-Date: {time}\\n\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\\n\n"
|
||||
"Language: {uid}\\n\n"
|
||||
"MIME-Version: 1.0\\n\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\\n\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
)
|
||||
PO_HEADER_COMMENT_COPYRIGHT = (
|
||||
"# Blender's translation file (po format).\n"
|
||||
"# Copyright (C) {year} The Blender Authors.\n"
|
||||
"# This file is distributed under the same license as the Blender package.\n"
|
||||
"#\n"
|
||||
)
|
||||
PO_HEADER_COMMENT = (
|
||||
"# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.\n"
|
||||
"#"
|
||||
)
|
||||
|
||||
TEMPLATE_ISO_ID = "__TEMPLATE__"
|
||||
|
||||
# Num buttons report their label with a trailing ': '...
|
||||
NUM_BUTTON_SUFFIX = ": "
|
||||
|
||||
# Undocumented operator placeholder string.
|
||||
UNDOC_OPS_STR = "(undocumented operator)"
|
||||
|
||||
# The gettext domain.
|
||||
DOMAIN = "blender"
|
||||
|
||||
# Our own "gettext" stuff.
|
||||
# File type (ext) to parse.
|
||||
PYGETTEXT_ALLOWED_EXTS = {".c", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx", ".h"}
|
||||
|
||||
# Max number of contexts into a BLT_I18N_MSGID_MULTI_CTXT macro...
|
||||
PYGETTEXT_MAX_MULTI_CTXT = 16
|
||||
|
||||
# Where to search contexts definitions, relative to SOURCE_DIR (defined below).
|
||||
PYGETTEXT_CONTEXTS_DEFSRC = os.path.join("source", "blender", "blentranslation", "BLT_translation.h")
|
||||
|
||||
# Regex to extract contexts defined in BLT_translation.h
|
||||
# XXX Not full-proof, but should be enough here!
|
||||
PYGETTEXT_CONTEXTS = "#define\\s+(BLT_I18NCONTEXT_[A-Z_0-9]+)\\s+\"([^\"]*)\""
|
||||
|
||||
# autopep8: off
|
||||
|
||||
# Keywords' regex.
|
||||
# XXX Most unfortunately, we can't use named back-references inside character sets,
|
||||
# which makes the REGEXES even more twisty... :/
|
||||
_str_base = (
|
||||
# Match void string
|
||||
"(?P<{_}1>[\"'])(?P={_}1)" # Get opening quote (' or "), and closing immediately.
|
||||
"|"
|
||||
# Or match non-void string
|
||||
"(?P<{_}2>[\"'])" # Get opening quote (' or ").
|
||||
"(?{capt}(?:"
|
||||
# This one is for crazy things like "hi \\\\\" folks!"...
|
||||
r"(?:(?!<\\)(?:\\\\)*\\(?=(?P={_}2)))|"
|
||||
# The most common case.
|
||||
".(?!(?P={_}2))"
|
||||
")*.)" # Don't forget the last char!
|
||||
"(?P={_}2)" # And closing quote.
|
||||
"(?:_ustr)?" # Optional trailing _ustr.
|
||||
)
|
||||
str_clean_re = _str_base.format(_="g", capt="P<clean>")
|
||||
_inbetween_str_re = (
|
||||
# XXX Strings may have comments between their pieces too, not only spaces!
|
||||
r"(?:\s*(?:"
|
||||
# A C comment
|
||||
r"/\*.*(?!\*/).\*/|"
|
||||
# Or a C++ one!
|
||||
r"//[^\n]*\n|"
|
||||
# Or some #defined value (like `BLI_STR_UTF8_BLACK_RIGHT_POINTING_SMALL_TRIANGLE`)
|
||||
# NOTE: This should be avoided at all cost, as it will simply make translation lookup fail.
|
||||
r"[ a-zA-Z0-9_]*"
|
||||
# And we are done!
|
||||
r")?)*"
|
||||
)
|
||||
# Here we have to consider two different cases (empty string and other).
|
||||
_str_whole_re = (
|
||||
_str_base.format(_="{_}1_", capt=":") +
|
||||
# Optional loop start, this handles "split" strings...
|
||||
"(?:(?<=[\"'])" + _inbetween_str_re + "(?=[\"'])(?:"
|
||||
+ _str_base.format(_="{_}2_", capt=":") +
|
||||
# End of loop.
|
||||
"))*"
|
||||
)
|
||||
_ctxt_re_gen = lambda uid: (
|
||||
r"(?P<ctxt_raw{uid}>(?:".format(uid=uid) +
|
||||
_str_whole_re.format(_="_ctxt{uid}".format(uid=uid)) +
|
||||
r")|(?:[A-Z_0-9]+))"
|
||||
)
|
||||
_ctxt_re = _ctxt_re_gen("")
|
||||
_msg_re = r"(?P<msg_raw>" + _str_whole_re.format(_="_msg") + r")"
|
||||
|
||||
class PyGettextKeyword:
|
||||
def __init__(self, re_expr, context_override=None):
|
||||
self.re_expr = re_expr
|
||||
self.context_override = context_override
|
||||
|
||||
self.re = re.compile(re_expr)
|
||||
self.search = self.re.search
|
||||
|
||||
PYGETTEXT_KEYWORDS = (() +
|
||||
tuple(PyGettextKeyword((r"{}\(\s*" + _msg_re + r"\s*\)").format(it))
|
||||
for it in ("IFACE_", "TIP_", "RPT_", "DATA_", "N_")) +
|
||||
|
||||
tuple(PyGettextKeyword((r"{}\(\s*" + _ctxt_re + r"\s*,\s*" + _msg_re + r"\s*\)").format(it))
|
||||
for it in ("CTX_IFACE_", "CTX_TIP_", "CTX_RPT_", "CTX_DATA_", "CTX_N_")) +
|
||||
|
||||
tuple(PyGettextKeyword(("{}\\((?:[^\"',]+,){{1,2}}\\s*" + _msg_re + r"\s*(?:\)|,)").format(it))
|
||||
for it in ("BKE_report", "BKE_reportf", "BKE_reports_prepend", "BKE_reports_prependf",
|
||||
"CTX_wm_operator_poll_msg_set", "WM_global_report", "WM_global_reportf",
|
||||
"button_disable")) +
|
||||
|
||||
# ED_undo_push() is used in Undo History menu and has the "Operator" context, same as operators.
|
||||
tuple(PyGettextKeyword(("{}\\((?:[^\"',]+,)\\s*" + _msg_re + r"\s*(?:\))").format(it),
|
||||
context_override='BLT_I18NCONTEXT_OPERATOR_DEFAULT')
|
||||
for it in ("ED_undo_push", "ED_undo_grouped_push")) +
|
||||
|
||||
# bmesh operator errors
|
||||
tuple(PyGettextKeyword(("{}\\((?:[^\"',]+,){{3}}\\s*" + _msg_re + r"\s*\)").format(it))
|
||||
for it in ("BMO_error_raise",)) +
|
||||
|
||||
# Modifier errors
|
||||
tuple(PyGettextKeyword(("{}\\((?:[^\"',]+,){{2}}\\s*" + _msg_re + r"\s*(?:\)|,)").format(it))
|
||||
for it in ("BKE_modifier_set_error",)) +
|
||||
|
||||
# Window manager job names.
|
||||
tuple(PyGettextKeyword(("{}\\((?:[^\"',]+,){{3}}\\s*" + _msg_re + r"\s*,").format(it))
|
||||
for it in ("WM_jobs_get",)) +
|
||||
|
||||
# Compositor and EEVEE messages.
|
||||
# Ends either with `)` (function call close), or `,` when there are extra formatting parameters.
|
||||
tuple(PyGettextKeyword((r"{}\(\s*" + _msg_re + r"\s*(?:\)|,)").format(it))
|
||||
for it in ("set_info_message", "info_append_i18n")) +
|
||||
|
||||
# This one is a tad more risky, but in practice would not expect a name/uid string parameter
|
||||
# (the second one in those functions) to ever have a comma in it, so think this is fine.
|
||||
tuple(PyGettextKeyword(("{}\\((?:[^,]+,){{2}}\\s*" + _msg_re + r"\s*(?:\)|,)").format(it))
|
||||
for it in ("modifier_subpanel_register", "gpencil_modifier_subpanel_register")) +
|
||||
|
||||
# Node socket declarations: context-less names.
|
||||
tuple(PyGettextKeyword((r"\.{}(?:<decl::.*?>\(|[^,]+,)\s*" + _msg_re + r"(?:,[^),]+)*\s*\)"
|
||||
r"(?![^;]*\.translation_context\()").format(it))
|
||||
for it in ("add_input", "add_output")) +
|
||||
|
||||
# Node socket declarations: names with contexts
|
||||
tuple(PyGettextKeyword((r"\.{}(?:<decl::.*?>\(|[^,]+,)\s*" + _msg_re +
|
||||
r"[^;]*\.translation_context\(\s*" + _ctxt_re + r"\s*\)").format(it))
|
||||
for it in ("add_input", "add_output")) +
|
||||
|
||||
# Node socket declarations: description and error messages
|
||||
tuple(PyGettextKeyword((r"\.{}\(\s*" + _msg_re + r"\s*\)").format(it))
|
||||
for it in ("description", "error_message_add")) +
|
||||
|
||||
# Node socket panels and labels from declarations: context-less names
|
||||
tuple(PyGettextKeyword((r"\.{}\(\s*" + _msg_re +
|
||||
r"\s*\)(?![^;]*\.translation_context\()[^;]*;").format(it))
|
||||
for it in ("short_label", "add_panel",)) +
|
||||
|
||||
# Node socket panels and labels from declarations: names with contexts
|
||||
tuple(PyGettextKeyword((r"\.{}\(\s*" + _msg_re + r"[^;]*\.translation_context\(\s*" +
|
||||
_ctxt_re + r"\s*\)").format(it))
|
||||
for it in ("short_label", "add_panel",)) +
|
||||
|
||||
# Dynamic node socket labels
|
||||
tuple(PyGettextKeyword((r"{}\(\s*[^,]+,\s*" + _msg_re + r"\s*\)").format(it))
|
||||
for it in ("node_sock_label",)) +
|
||||
|
||||
# Geometry Nodes field inputs
|
||||
(PyGettextKeyword(r"FieldInput\(CPPType::get<.*?>\(\),\s*" + _msg_re + r"\s*\)"),) +
|
||||
|
||||
# bUnitDef unit names
|
||||
(PyGettextKeyword(r"/\*name_display\*/\s*" + _msg_re + r"\s*,"),) +
|
||||
|
||||
tuple(PyGettextKeyword(
|
||||
(r"{}\(\s*"
|
||||
+ _msg_re
|
||||
+ r"\s*,\s*(?:"
|
||||
+ r"\s*,\s*)?(?:".join(_ctxt_re_gen(i) for i in range(PYGETTEXT_MAX_MULTI_CTXT))
|
||||
+ r")?\s*,?\s*\)"
|
||||
).format(it)
|
||||
) for it in ("BLT_I18N_MSGID_MULTI_CTXT",))
|
||||
)
|
||||
|
||||
# autopep8: on
|
||||
|
||||
|
||||
# Check printf mismatches between msgid and msgstr.
|
||||
CHECK_PRINTF_FORMAT = (
|
||||
r"(?!<%)(?:%%)*%" # Beginning, with handling for crazy things like '%%%%%s'
|
||||
r"[-+#0]?" # Flags (note: do not add the ' ' (space) flag here, generates too much false positives!)
|
||||
r"(?:\*|[0-9]+)?" # Width
|
||||
r"(?:\.(?:\*|[0-9]+))?" # Precision
|
||||
r"(?:[hljztL]|hh|ll)?" # Length
|
||||
r"[tldiuoxXfFeEgGaAcspn]" # Specifiers (note we have Blender-specific %t and %l ones too)
|
||||
)
|
||||
|
||||
# Should po parser warn when finding a first letter not capitalized?
|
||||
WARN_MSGID_NOT_CAPITALIZED = True
|
||||
|
||||
# Strings that should not raise above warning!
|
||||
WARN_MSGID_NOT_CAPITALIZED_ALLOWED = {
|
||||
"", # Simplifies things... :p
|
||||
"ac3",
|
||||
"along X",
|
||||
"along Y",
|
||||
"along Z",
|
||||
"along %s X",
|
||||
"along %s Y",
|
||||
"along %s Z",
|
||||
"along local Z",
|
||||
"arccos(A)",
|
||||
"arcsin(A)",
|
||||
"arctan(A)",
|
||||
"ascii",
|
||||
"author", # Addons' field. :/
|
||||
"bItasc",
|
||||
"blender.org",
|
||||
"bytes",
|
||||
"color_index is invalid",
|
||||
"cos(A)",
|
||||
"cosh(A)",
|
||||
"dB", # dB audio power unit.
|
||||
"dbl-", # Compacted for 'double', for keymap items.
|
||||
"description", # Addons' field. :/
|
||||
"dx",
|
||||
"fBM",
|
||||
"flac",
|
||||
"fps: %.2f",
|
||||
"fps: %i",
|
||||
"gimbal",
|
||||
"global",
|
||||
"glTF 2.0 (.glb/.gltf)",
|
||||
"glTF Binary (.glb)",
|
||||
"glTF Embedded (.gltf)",
|
||||
"glTF Material Output",
|
||||
"glTF Original PBR data",
|
||||
"glTF Separate (.gltf + .bin + textures)",
|
||||
"gltfpack",
|
||||
"glTFpack file path",
|
||||
"invoke() needs to be called before execute()",
|
||||
"iScale",
|
||||
"iso-8859-15",
|
||||
"iTaSC",
|
||||
"iTaSC parameters",
|
||||
"kb",
|
||||
"local",
|
||||
"location", # Addons' field. :/
|
||||
"locking %s X",
|
||||
"locking %s Y",
|
||||
"locking %s Z",
|
||||
"mkv",
|
||||
"mm",
|
||||
"mp2",
|
||||
"mp3",
|
||||
"normal",
|
||||
"ogg",
|
||||
"oneAPI",
|
||||
"p0",
|
||||
"parent_index should not be less than -1: %d",
|
||||
"parent_index (%d) should be less than the number of bone collections (%d)",
|
||||
"px",
|
||||
"re",
|
||||
"res",
|
||||
"rv",
|
||||
"seconds",
|
||||
"sin(A)",
|
||||
"sin(x) / x",
|
||||
"sinh(A)",
|
||||
"sqrt(x*x+y*y+z*z)",
|
||||
"sRGB",
|
||||
"sRGB display space",
|
||||
"sRGB display space with Filmic view transform",
|
||||
"sRGB IEC 61966-2-1 compound (piece-wise) encoding",
|
||||
"tan(A)",
|
||||
"tanh(A)",
|
||||
"utf-8",
|
||||
"uv_on_emitter() requires a modifier from an evaluated object",
|
||||
"var",
|
||||
"vBVH",
|
||||
"view",
|
||||
"wav",
|
||||
"wmOwnerID '%s' not in workspace '%s'",
|
||||
"y",
|
||||
"y = (Ax + B)",
|
||||
# ID plural names, defined in IDTypeInfo.
|
||||
"armatures",
|
||||
"brushes",
|
||||
"cache_files",
|
||||
"cameras",
|
||||
"collections",
|
||||
"curves",
|
||||
"fonts",
|
||||
"grease_pencils",
|
||||
"hair_curves",
|
||||
"ipos",
|
||||
"lattices",
|
||||
"libraries",
|
||||
"lightprobes",
|
||||
"lights",
|
||||
"linestyles",
|
||||
"link_placeholders",
|
||||
"masks",
|
||||
"metaballs",
|
||||
"materials",
|
||||
"meshes",
|
||||
"movieclips",
|
||||
"node_groups",
|
||||
"objects",
|
||||
"paint_curves",
|
||||
"palettes",
|
||||
"particles",
|
||||
"pointclouds",
|
||||
"screens",
|
||||
"shape_keys",
|
||||
"sounds",
|
||||
"speakers",
|
||||
"texts",
|
||||
"textures",
|
||||
"volumes",
|
||||
"window_managers",
|
||||
"workspaces",
|
||||
"worlds",
|
||||
# Sub-strings.
|
||||
"all",
|
||||
"all and invert unselected",
|
||||
"and AMD driver version %s or newer",
|
||||
"and AMD Radeon Pro %s driver or newer",
|
||||
"and NVIDIA driver version %s or newer",
|
||||
"and Windows driver version %s or newer",
|
||||
"available with",
|
||||
"brown fox",
|
||||
"can't save image while rendering",
|
||||
"category",
|
||||
"constructive modifier",
|
||||
"cursor",
|
||||
"custom",
|
||||
"custom matrix",
|
||||
"custom orientation",
|
||||
"drag-",
|
||||
"edge data",
|
||||
"exp(A)",
|
||||
"expected a timeline/animation area to be active",
|
||||
"expected a view3d region",
|
||||
"expected a view3d region & editcurve",
|
||||
"expected a view3d region & editmesh",
|
||||
"face data",
|
||||
"gimbal",
|
||||
"global",
|
||||
"glTF Settings",
|
||||
"image file not found",
|
||||
"image format is read-only",
|
||||
"image path can't be written to",
|
||||
"in %i days",
|
||||
"in %i hours",
|
||||
"in %i minutes",
|
||||
"in memory to enable editing!",
|
||||
"in the asset shelf.",
|
||||
"insufficient content",
|
||||
"into",
|
||||
"jumps over",
|
||||
"left",
|
||||
"local",
|
||||
"matrices", "no matrices",
|
||||
"multi-res modifier",
|
||||
"name",
|
||||
"non-triangle face",
|
||||
"normal",
|
||||
"on {:%Y-%m-%d}",
|
||||
"or AMD with macOS %s or newer",
|
||||
"parent",
|
||||
"performance impact!",
|
||||
"positions", "no positions",
|
||||
"read",
|
||||
"remove",
|
||||
"right",
|
||||
"selected",
|
||||
"selected and lock unselected",
|
||||
"selected and unlock unselected",
|
||||
"screen",
|
||||
"the lazy dog",
|
||||
"this legacy pose library to pose assets",
|
||||
"to the top level of the tree",
|
||||
"unable to load movie clip",
|
||||
"unable to load text",
|
||||
"unable to open the file",
|
||||
"unknown error reading file",
|
||||
"unknown error statting file",
|
||||
"unknown error writing file",
|
||||
"unselected",
|
||||
"unsupported font format",
|
||||
"unsupported format",
|
||||
"unsupported image format",
|
||||
"unsupported movie clip format",
|
||||
"untitled",
|
||||
"vertex data",
|
||||
"verts only",
|
||||
"view",
|
||||
"virtual parents",
|
||||
"which was replaced by the Asset Browser",
|
||||
"within seconds",
|
||||
"write",
|
||||
}
|
||||
WARN_MSGID_NOT_CAPITALIZED_ALLOWED |= set(lng[2] for lng in LANGUAGES)
|
||||
|
||||
WARN_MSGID_END_POINT_ALLOWED = {
|
||||
"Cannot figure out which object this bone belongs to.",
|
||||
"Circle|Alt .",
|
||||
"Float Neg. Exp.",
|
||||
"Max Ext.",
|
||||
"Newer graphics drivers may be available to improve Blender support.",
|
||||
"Not assigned to any bone collection.",
|
||||
"Numpad .",
|
||||
"Pad.",
|
||||
"Please file a bug report.",
|
||||
" RNA Path: bpy.types.",
|
||||
"Temp. Diff.",
|
||||
"Temperature Diff.",
|
||||
"The program will now close.",
|
||||
"Your graphics card or driver has limited support. It may work, but with issues.",
|
||||
"Your graphics card or driver is not supported.",
|
||||
"Invalid surface UVs on %d curves.",
|
||||
"The pose library moved.",
|
||||
"in the asset shelf.",
|
||||
"Remove, local files not found.",
|
||||
"Remove all files in \"{}\".",
|
||||
"Remove, keeping local files.",
|
||||
}
|
||||
|
||||
PARSER_CACHE_HASH = 'sha1'
|
||||
|
||||
PARSER_TEMPLATE_ID = "__POT__"
|
||||
PARSER_PY_ID = "__PY__"
|
||||
|
||||
PARSER_PY_MARKER_BEGIN = "\n# ##### BEGIN AUTOGENERATED I18N SECTION #####\n"
|
||||
PARSER_PY_MARKER_END = "\n# ##### END AUTOGENERATED I18N SECTION #####\n"
|
||||
|
||||
PARSER_MAX_FILE_SIZE = 2 ** 24 # in bytes, i.e. 16 Mb.
|
||||
|
||||
###############################################################################
|
||||
# PATHS
|
||||
###############################################################################
|
||||
|
||||
# The Python3 executable.You’ll likely have to edit it in your user_settings.py
|
||||
# if you’re under Windows.
|
||||
PYTHON3_EXEC = "python3"
|
||||
|
||||
# The Blender executable!
|
||||
# This is just an example, you’ll have to edit it in your user_settings.py!
|
||||
BLENDER_EXEC = os.path.abspath(os.path.join("foo", "bar", "blender"))
|
||||
# check for blender.bin
|
||||
if not os.path.exists(BLENDER_EXEC):
|
||||
if os.path.exists(BLENDER_EXEC + ".bin"):
|
||||
BLENDER_EXEC = BLENDER_EXEC + ".bin"
|
||||
|
||||
# The gettext msgfmt "compiler". You’ll likely have to edit it in your user_settings.py if you’re under Windows.
|
||||
GETTEXT_MSGFMT_EXECUTABLE = "msgfmt"
|
||||
|
||||
# The FriBidi C compiled library (.so under Linux, `.dll` under windows...).
|
||||
# You’ll likely have to edit it in your `user_settings.py` if you’re under Windows., e.g. using the included one:
|
||||
# `FRIBIDI_LIB = os.path.join(TOOLS_DIR, "libfribidi.dll")`
|
||||
FRIBIDI_LIB = "libfribidi.so.0"
|
||||
|
||||
# The name of the (currently empty) file that must be present in a po's directory to enable RTL-preprocess.
|
||||
RTL_PREPROCESS_FILE = "is_rtl"
|
||||
|
||||
# The Blender source root path.
|
||||
# This is just an example, you’ll have to override it in your user_settings.py!
|
||||
SOURCE_DIR = os.path.abspath(os.path.join("blender"))
|
||||
|
||||
# The bf-translation repository (you'll have to override this in your user_settings.py).
|
||||
I18N_DIR = os.path.abspath(os.path.join("i18n"))
|
||||
|
||||
# The 'work' path to PO files (relative to I18N_DIR).
|
||||
REL_WORK_DIR = os.path.join("")
|
||||
|
||||
|
||||
# The path to the Blender translation directory (relative to SOURCE_DIR).
|
||||
REL_BLENDER_I18N_DIR = os.path.join("locale")
|
||||
|
||||
# The /po path of the Blender translation directory (relative to REL_BLENDER_I18N_DIR).
|
||||
REL_BLENDER_I18N_PO_DIR = os.path.join("po")
|
||||
|
||||
|
||||
# The Blender source path to check for i18n macros (relative to SOURCE_DIR).
|
||||
REL_POTFILES_SOURCE_DIR = os.path.join("source")
|
||||
|
||||
# Where to search for preset names (relative to SOURCE_DIR).
|
||||
REL_PRESETS_DIR = os.path.join("scripts", "presets")
|
||||
|
||||
# Where to search for templates (relative to SOURCE_DIR).
|
||||
REL_TEMPLATES_DIR = os.path.join("scripts", "startup", "bl_app_templates_system")
|
||||
|
||||
# Name of the built-in asset catalog file.
|
||||
ASSET_CATALOG_FILE = "blender_assets.cats.txt"
|
||||
|
||||
# The template messages file (relative to I18N_DIR).
|
||||
REL_FILE_NAME_POT = os.path.join(REL_WORK_DIR, DOMAIN + ".pot")
|
||||
|
||||
|
||||
# Mo path generator for a given language (relative to any "locale" directory).
|
||||
MO_PATH_ROOT_RELATIVE = os.path.join("locale")
|
||||
MO_PATH_TEMPLATE_RELATIVE = os.path.join(MO_PATH_ROOT_RELATIVE, "{}", "LC_MESSAGES")
|
||||
|
||||
# Mo file name.
|
||||
MO_FILE_NAME = DOMAIN + ".mo"
|
||||
|
||||
# Where to search for py files that may contain ui strings (relative to one of the 'resource_path' of Blender).
|
||||
CUSTOM_PY_UI_FILES = [
|
||||
os.path.join("scripts", "startup", "bl_ui"),
|
||||
os.path.join("scripts", "startup", "bl_operators"),
|
||||
os.path.join("scripts", "modules", "rna_prop_ui.py"),
|
||||
os.path.join("scripts", "modules", "rna_keymap_ui.py"),
|
||||
os.path.join("scripts", "modules", "_bpy_types.py"),
|
||||
os.path.join("scripts", "presets", "keyconfig"),
|
||||
]
|
||||
|
||||
# An optional text file listing files to force include/exclude from py_xgettext process.
|
||||
SRC_POTFILES = ""
|
||||
|
||||
# A cache storing validated msgids, to avoid re-spellchecking them.
|
||||
SPELL_CACHE = os.path.join("/tmp", ".spell_cache")
|
||||
|
||||
# Threshold defining whether a new msgid is similar enough with an old one to reuse its translation...
|
||||
SIMILAR_MSGID_THRESHOLD = 0.75
|
||||
|
||||
# Additional import paths to add to `sys.path` (';' separated)...
|
||||
INTERN_PY_SYS_PATHS = ""
|
||||
|
||||
# Custom override settings must be one directory above i18n tools itself!
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
try:
|
||||
from bl_i18n_settings_override import *
|
||||
except ImportError: # If no i18n_override_settings available, it’s no error!
|
||||
pass
|
||||
|
||||
# Override with custom user settings, if available.
|
||||
try:
|
||||
from settings_user import *
|
||||
except ImportError: # If no user_settings available, it’s no error!
|
||||
pass
|
||||
|
||||
|
||||
for p in set(INTERN_PY_SYS_PATHS.split(";")):
|
||||
if p:
|
||||
sys.path.append(p)
|
||||
|
||||
|
||||
# The settings class itself!
|
||||
def _do_get(ref, path):
|
||||
return os.path.normpath(os.path.join(ref, path))
|
||||
|
||||
|
||||
def _do_set(ref, path):
|
||||
path = os.path.normpath(path)
|
||||
# If given path is absolute, make it relative to current ref one (else we consider it is already the case!)
|
||||
if os.path.isabs(path):
|
||||
# can't always find the relative path (between drive letters on windows)
|
||||
try:
|
||||
return os.path.relpath(path, ref)
|
||||
except ValueError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def _gen_get_set_path(ref, name):
|
||||
def _get(self):
|
||||
return _do_get(getattr(self, ref), getattr(self, name))
|
||||
|
||||
def _set(self, value):
|
||||
setattr(self, name, _do_set(getattr(self, ref), value))
|
||||
return _get, _set
|
||||
|
||||
|
||||
def _check_valid_data(uid, val):
|
||||
return not uid.startswith("_") and type(val) not in tuple(types.__dict__.values()) + (type,)
|
||||
|
||||
|
||||
class I18nSettings:
|
||||
"""
|
||||
Class allowing persistence of our settings!
|
||||
Saved in JSON format, so settings should be JSON'able objects!
|
||||
"""
|
||||
_settings = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
# Addon preferences are singleton by definition, so is this class!
|
||||
if not I18nSettings._settings:
|
||||
cls._settings = super(I18nSettings, cls).__new__(cls)
|
||||
cls._settings.__dict__ = {uid: val for uid, val in globals().items() if _check_valid_data(uid, val)}
|
||||
return I18nSettings._settings
|
||||
|
||||
def __getstate__(self):
|
||||
return self.to_dict()
|
||||
|
||||
def __setstate__(self, mapping):
|
||||
return self.from_dict(mapping)
|
||||
|
||||
def from_dict(self, mapping):
|
||||
# Special case... :/
|
||||
if "INTERN_PY_SYS_PATHS" in mapping:
|
||||
self.PY_SYS_PATHS = mapping["INTERN_PY_SYS_PATHS"]
|
||||
self.__dict__.update(mapping)
|
||||
|
||||
def to_dict(self):
|
||||
glob = globals()
|
||||
return {uid: val for uid, val in self.__dict__.items() if _check_valid_data(uid, val) and uid in glob}
|
||||
|
||||
def from_json(self, string):
|
||||
self.from_dict(dict(json.loads(string)))
|
||||
|
||||
def to_json(self):
|
||||
# Only save the diff from default i18n_settings!
|
||||
glob = globals()
|
||||
export_dict = {
|
||||
uid: val for uid, val in self.__dict__.items()
|
||||
if _check_valid_data(uid, val) and glob.get(uid) != val
|
||||
}
|
||||
return json.dumps(export_dict)
|
||||
|
||||
def load(self, fname, reset=False):
|
||||
reset = reset or fname is None
|
||||
if reset:
|
||||
self.__dict__ = {uid: data for uid, data in globals().items() if not uid.startswith("_")}
|
||||
if fname is None:
|
||||
return
|
||||
if isinstance(fname, str):
|
||||
if not os.path.isfile(fname):
|
||||
# Assume it is already real JSON string.
|
||||
self.from_json(fname)
|
||||
return
|
||||
with open(fname, encoding="utf8") as f:
|
||||
self.from_json(f.read())
|
||||
# Else assume fname is already a file(like) object!
|
||||
else:
|
||||
self.from_json(fname.read())
|
||||
|
||||
def save(self, fname):
|
||||
if isinstance(fname, str):
|
||||
with open(fname, 'w', encoding="utf8") as f:
|
||||
f.write(self.to_json())
|
||||
# Else assume fname is already a file(like) object!
|
||||
else:
|
||||
fname.write(self.to_json())
|
||||
|
||||
WORK_DIR = property(*(_gen_get_set_path("I18N_DIR", "REL_WORK_DIR")))
|
||||
BLENDER_I18N_ROOT = property(*(_gen_get_set_path("SOURCE_DIR", "REL_BLENDER_I18N_DIR")))
|
||||
BLENDER_I18N_PO_DIR = property(*(_gen_get_set_path("BLENDER_I18N_ROOT", "REL_BLENDER_I18N_PO_DIR")))
|
||||
POTFILES_SOURCE_DIR = property(*(_gen_get_set_path("SOURCE_DIR", "REL_POTFILES_SOURCE_DIR")))
|
||||
PRESETS_DIR = property(*(_gen_get_set_path("SOURCE_DIR", "REL_PRESETS_DIR")))
|
||||
TEMPLATES_DIR = property(*(_gen_get_set_path("SOURCE_DIR", "REL_TEMPLATES_DIR")))
|
||||
FILE_NAME_POT = property(*(_gen_get_set_path("I18N_DIR", "REL_FILE_NAME_POT")))
|
||||
|
||||
def _get_py_sys_paths(self):
|
||||
return self.INTERN_PY_SYS_PATHS
|
||||
|
||||
def _set_py_sys_paths(self, val):
|
||||
old_paths = set(self.INTERN_PY_SYS_PATHS.split(";")) - {""}
|
||||
new_paths = set(val.split(";")) - {""}
|
||||
for p in old_paths - new_paths:
|
||||
if p in sys.path:
|
||||
sys.path.remove(p)
|
||||
for p in new_paths - old_paths:
|
||||
sys.path.append(p)
|
||||
self.INTERN_PY_SYS_PATHS = val
|
||||
PY_SYS_PATHS = property(_get_py_sys_paths, _set_py_sys_paths)
|
||||
@@ -0,0 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2002-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import os
|
||||
|
||||
import settings
|
||||
1709
blender-5.2.0/scripts/modules/_bl_i18n_utils/utils.py
Normal file
1709
blender-5.2.0/scripts/modules/_bl_i18n_utils/utils.py
Normal file
File diff suppressed because it is too large
Load Diff
144
blender-5.2.0/scripts/modules/_bl_i18n_utils/utils_cli.py
Normal file
144
blender-5.2.0/scripts/modules/_bl_i18n_utils/utils_cli.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# SPDX-FileCopyrightText: 2020-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Some useful operations from utils' I18nMessages class exposed as a CLI.
|
||||
|
||||
import os
|
||||
|
||||
if __package__ is None:
|
||||
import settings as settings_i18n
|
||||
import utils as utils_i18n
|
||||
import utils_languages_menu
|
||||
else:
|
||||
from . import settings as settings_i18n
|
||||
from . import utils as utils_i18n
|
||||
from . import utils_languages_menu
|
||||
|
||||
|
||||
def update_po(args, settings):
|
||||
pot = utils_i18n.I18nMessages(uid=None, kind='PO', src=args.template, settings=settings)
|
||||
if os.path.isfile(args.dst):
|
||||
uid = os.path.splitext(os.path.basename(args.dst))[0]
|
||||
po = utils_i18n.I18nMessages(uid=uid, kind='PO', src=args.dst, settings=settings)
|
||||
po.update(pot)
|
||||
else:
|
||||
po = pot
|
||||
po.write(kind="PO", dest=args.dst)
|
||||
|
||||
|
||||
def cleanup_po(args, settings):
|
||||
uid = os.path.splitext(os.path.basename(args.src))[0]
|
||||
if not args.dst:
|
||||
args.dst = args.src
|
||||
po = utils_i18n.I18nMessages(uid=uid, kind='PO', src=args.src, settings=settings)
|
||||
po.check(fix=True)
|
||||
po.clean_commented()
|
||||
po.write(kind="PO", dest=args.dst)
|
||||
|
||||
|
||||
def strip_po(args, settings):
|
||||
uid = os.path.splitext(os.path.basename(args.src))[0]
|
||||
if not args.dst:
|
||||
args.dst = args.src
|
||||
po = utils_i18n.I18nMessages(uid=uid, kind='PO', src=args.src, settings=settings)
|
||||
po.clean_commented()
|
||||
po.write(kind="PO_COMPACT", dest=args.dst)
|
||||
|
||||
|
||||
def rtl_process_po(args, settings):
|
||||
uid = os.path.splitext(os.path.basename(args.src))[0]
|
||||
if not args.dst:
|
||||
args.dst = args.src
|
||||
po = utils_i18n.I18nMessages(uid=uid, kind='PO', src=args.src, settings=settings)
|
||||
po.rtl_process()
|
||||
po.write(kind="PO", dest=args.dst)
|
||||
|
||||
|
||||
def language_menu(args, settings):
|
||||
# 'DEFAULT' and en_US are always valid, fully-translated "languages"!
|
||||
stats = {"DEFAULT": 1.0, "en_US": 1.0}
|
||||
|
||||
po_to_uid = {
|
||||
os.path.basename(po_path_work): uid
|
||||
for can_use, uid, _num_id, _name, _isocode, po_path_work
|
||||
in utils_i18n.list_po_dir(settings.WORK_DIR, settings)
|
||||
if can_use
|
||||
}
|
||||
for po_dir in os.listdir(settings.WORK_DIR):
|
||||
po_dir = os.path.join(settings.WORK_DIR, po_dir)
|
||||
if not os.path.isdir(po_dir):
|
||||
continue
|
||||
for po_path in os.listdir(po_dir):
|
||||
uid = po_to_uid.get(po_path, None)
|
||||
# print("Checking {:s}, found uid {:s}".format(po_path, uid))
|
||||
po_path = os.path.join(po_dir, po_path)
|
||||
if uid is not None:
|
||||
po = utils_i18n.I18nMessages(uid=uid, kind='PO', src=po_path, settings=settings)
|
||||
stats[uid] = po.nbr_trans_msgs / po.nbr_msgs if po.nbr_msgs > 0 else 0
|
||||
utils_languages_menu.gen_menu_file(stats, settings)
|
||||
|
||||
|
||||
def main():
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Tool to perform common actions over PO/MO files.")
|
||||
parser.add_argument(
|
||||
'-s', '--settings', default=None,
|
||||
help="Override (some) default settings. Either a JSON file name, or a JSON string.",
|
||||
)
|
||||
sub_parsers = parser.add_subparsers()
|
||||
|
||||
sub_parser = sub_parsers.add_parser('update_po', help="Update a PO file from a given POT template file")
|
||||
sub_parser.add_argument(
|
||||
'--template', metavar='template.pot', required=True,
|
||||
help="The source pot file to use as template for the update.",
|
||||
)
|
||||
sub_parser.add_argument('--dst', metavar='dst.po', required=True, help="The destination po to update.")
|
||||
sub_parser.set_defaults(func=update_po)
|
||||
|
||||
sub_parser = sub_parsers.add_parser(
|
||||
'cleanup_po',
|
||||
help="Cleanup a PO file (check for and fix some common errors, remove commented messages).",
|
||||
)
|
||||
sub_parser.add_argument('--src', metavar='src.po', required=True, help="The source po file to clean up.")
|
||||
sub_parser.add_argument('--dst', metavar='dst.po', help="The destination po to write to.")
|
||||
sub_parser.set_defaults(func=cleanup_po)
|
||||
|
||||
sub_parser = sub_parsers.add_parser(
|
||||
'strip_po',
|
||||
help="Reduce all non-essential data from given PO file (reduce its size).",
|
||||
)
|
||||
sub_parser.add_argument('--src', metavar='src.po', required=True, help="The source po file to strip.")
|
||||
sub_parser.add_argument('--dst', metavar='dst.po', help="The destination po to write to.")
|
||||
sub_parser.set_defaults(func=strip_po)
|
||||
|
||||
sub_parser = sub_parsers.add_parser(
|
||||
'rtl_process_po',
|
||||
help="Pre-process PO files for RTL languages.",
|
||||
)
|
||||
sub_parser.add_argument('--src', metavar='src.po', required=True, help="The source po file to process.")
|
||||
sub_parser.add_argument('--dst', metavar='dst.po', help="The destination po to write to.")
|
||||
sub_parser.set_defaults(func=rtl_process_po)
|
||||
|
||||
sub_parser = sub_parsers.add_parser(
|
||||
'language_menu',
|
||||
help="Generate the text file used by Blender to create its language menu.",
|
||||
)
|
||||
sub_parser.set_defaults(func=language_menu)
|
||||
|
||||
args = parser.parse_args(sys.argv[1:])
|
||||
|
||||
settings = settings_i18n.I18nSettings()
|
||||
settings.load(args.settings)
|
||||
|
||||
if getattr(args, "template", None) is not None:
|
||||
settings.FILE_NAME_POT = args.template
|
||||
|
||||
args.func(args=args, settings=settings)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n\n *** Running {} *** \n".format(__file__))
|
||||
main()
|
||||
68
blender-5.2.0/scripts/modules/_bl_i18n_utils/utils_languages_menu.py
Executable file
68
blender-5.2.0/scripts/modules/_bl_i18n_utils/utils_languages_menu.py
Executable file
@@ -0,0 +1,68 @@
|
||||
# SPDX-FileCopyrightText: 2013-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Update "languages" text file used by Blender at runtime to build translations menu.
|
||||
|
||||
|
||||
OK = 0
|
||||
MISSING = 1
|
||||
TOOLOW = 2
|
||||
SKIPPED = 3
|
||||
FLAG_MESSAGES = {
|
||||
OK: "",
|
||||
MISSING: "No translation yet.",
|
||||
TOOLOW: "Not complete enough to be included.",
|
||||
SKIPPED: "Skipped (see IMPORT_LANGUAGES_SKIP in settings.py).",
|
||||
}
|
||||
|
||||
|
||||
def gen_menu_file(stats, settings):
|
||||
# Generate languages file content used by Blender's i18n system.
|
||||
# First, match all entries in LANGUAGES to a `lang` in stats, if possible!
|
||||
# Returns a iterable of text lines.
|
||||
tmp = []
|
||||
for uid_num, label, uid in settings.LANGUAGES:
|
||||
if uid in stats:
|
||||
if uid in settings.IMPORT_LANGUAGES_SKIP:
|
||||
tmp.append((stats[uid], uid_num, label, uid, SKIPPED))
|
||||
else:
|
||||
tmp.append((stats[uid], uid_num, label, uid, OK))
|
||||
else:
|
||||
tmp.append((0.0, uid_num, label, uid, MISSING))
|
||||
stats = tmp
|
||||
stats = sorted(stats, key=lambda it: it[0], reverse=True)
|
||||
langs = []
|
||||
highest_uid = 0
|
||||
for lvl, uid_num, label, uid, flag in stats:
|
||||
if lvl < settings.IMPORT_MIN_LEVEL and flag == OK:
|
||||
flag = TOOLOW
|
||||
if uid_num == 0:
|
||||
# Insert default language (Automatic) at index 0, after sorting.
|
||||
default_lang = (uid_num, label, uid, flag, lvl)
|
||||
continue
|
||||
langs.append((uid_num, label, uid, flag, lvl))
|
||||
if abs(uid_num) > highest_uid:
|
||||
highest_uid = abs(uid_num)
|
||||
# Sort languages by name.
|
||||
langs.sort(key=lambda it: it[1])
|
||||
langs.insert(0, default_lang)
|
||||
data_lines = [
|
||||
"# File used by Blender to know which languages (translations) are available, ",
|
||||
"# and to generate translation menu.",
|
||||
"#",
|
||||
"# File format:",
|
||||
"# ID:MENULABEL:ISOCODE",
|
||||
"# ID must be unique, except for 0 value (marks categories for menu).",
|
||||
"# Line starting with a # are comments!",
|
||||
"#",
|
||||
"# Automatically generated by _bl_i18n_utils/utils_languages_menu.py script.",
|
||||
"# Highest ID currently in use: {}".format(highest_uid),
|
||||
]
|
||||
for uid_num, label, uid, flag, lvl in langs:
|
||||
if flag == OK:
|
||||
data_lines.append("{}:{}:{}:{}%".format(uid_num, label, uid, round(lvl * 100)))
|
||||
else:
|
||||
# Non-existing, commented entry!
|
||||
data_lines.append("# {} #{}:{}:{}:{}%".format(FLAG_MESSAGES[flag], uid_num, label, uid, round(lvl * 100)))
|
||||
return data_lines
|
||||
228
blender-5.2.0/scripts/modules/_bl_i18n_utils/utils_rtl.py
Executable file
228
blender-5.2.0/scripts/modules/_bl_i18n_utils/utils_rtl.py
Executable file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2012-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Pre-process right-to-left languages.
|
||||
# You can use it either standalone, or through import_po_from_branches or
|
||||
# update_trunk.
|
||||
#
|
||||
# Notes: This has been tested on Linux, not 100% it will work nicely on
|
||||
# Windows or OsX.
|
||||
# This uses ctypes, as there is no py3 binding for fribidi currently.
|
||||
# This implies you only need the compiled C library to run it.
|
||||
# Finally, note that it handles some formatting/escape codes (like
|
||||
# \", %s, %x12, %.4f, etc.), protecting them from ugly (evil) fribidi,
|
||||
# which seems completely unaware of such things (as unicode is...).
|
||||
|
||||
import ctypes
|
||||
import re
|
||||
|
||||
|
||||
# define FRIBIDI_MASK_NEUTRAL 0x00000040L /* Is neutral */
|
||||
FRIBIDI_PAR_ON = 0x00000040
|
||||
|
||||
|
||||
# define FRIBIDI_FLAG_SHAPE_MIRRORING 0x00000001
|
||||
# define FRIBIDI_FLAG_REORDER_NSM 0x00000002
|
||||
|
||||
# define FRIBIDI_FLAG_SHAPE_ARAB_PRES 0x00000100
|
||||
# define FRIBIDI_FLAG_SHAPE_ARAB_LIGA 0x00000200
|
||||
# define FRIBIDI_FLAG_SHAPE_ARAB_CONSOLE 0x00000400
|
||||
|
||||
# define FRIBIDI_FLAG_REMOVE_BIDI 0x00010000
|
||||
# define FRIBIDI_FLAG_REMOVE_JOINING 0x00020000
|
||||
# define FRIBIDI_FLAG_REMOVE_SPECIALS 0x00040000
|
||||
|
||||
# define FRIBIDI_FLAGS_DEFAULT ( \
|
||||
# FRIBIDI_FLAG_SHAPE_MIRRORING | \
|
||||
# FRIBIDI_FLAG_REORDER_NSM | \
|
||||
# FRIBIDI_FLAG_REMOVE_SPECIALS )
|
||||
|
||||
# define FRIBIDI_FLAGS_ARABIC ( \
|
||||
# FRIBIDI_FLAG_SHAPE_ARAB_PRES | \
|
||||
# FRIBIDI_FLAG_SHAPE_ARAB_LIGA )
|
||||
|
||||
FRIBIDI_FLAG_SHAPE_MIRRORING = 0x00000001
|
||||
FRIBIDI_FLAG_REORDER_NSM = 0x00000002
|
||||
FRIBIDI_FLAG_REMOVE_SPECIALS = 0x00040000
|
||||
|
||||
FRIBIDI_FLAG_SHAPE_ARAB_PRES = 0x00000100
|
||||
FRIBIDI_FLAG_SHAPE_ARAB_LIGA = 0x00000200
|
||||
|
||||
FRIBIDI_FLAGS_DEFAULT = FRIBIDI_FLAG_SHAPE_MIRRORING | FRIBIDI_FLAG_REORDER_NSM | FRIBIDI_FLAG_REMOVE_SPECIALS
|
||||
|
||||
FRIBIDI_FLAGS_ARABIC = FRIBIDI_FLAG_SHAPE_ARAB_PRES | FRIBIDI_FLAG_SHAPE_ARAB_LIGA
|
||||
|
||||
|
||||
MENU_DETECT_REGEX = re.compile("%x\\d+\\|")
|
||||
|
||||
|
||||
##### Kernel processing functions. #####
|
||||
def protect_format_seq(msg):
|
||||
"""
|
||||
Find some specific escaping/formatting sequences (like \", %s, etc.,
|
||||
and protect them from any modification!
|
||||
|
||||
NOTE: This is not covering all exotic 'printf' formatting cases!
|
||||
It also only covers the minimal `{}` syntax for the modern `format` syntax.
|
||||
"""
|
||||
# LRM = "\u200E"
|
||||
# RLM = "\u200F"
|
||||
LRE = "\u202A"
|
||||
# RLE = "\u202B"
|
||||
PDF = "\u202C"
|
||||
LRO = "\u202D"
|
||||
# RLO = "\u202E"
|
||||
# uctrl = {LRE, RLE, PDF, LRO, RLO}
|
||||
|
||||
# 'printf' format, from https://cplusplus.com/reference/cstdio/printf/
|
||||
printf_format_flags = set("-+ #0")
|
||||
printf_format_widthprec = set(".0123456789") # For width and precision.
|
||||
printf_format_datasize = set("hljztL")
|
||||
printf_format_codes = set("diuoxXfFeEgGaAcsp")
|
||||
# 'fmt::format' (and Python 'format()'),
|
||||
# see https://fmt.dev/12.0/syntax/ and https://docs.python.org/3.13/library/string.html#formatstrings
|
||||
fmt_format_widthprec = set(".0123456789") # For width and precision.
|
||||
fmt_format_codes = set("aAbBcdeEfFgGnopsxX?%")
|
||||
|
||||
if not msg:
|
||||
return msg
|
||||
elif MENU_DETECT_REGEX.search(msg):
|
||||
# An ugly "menu" message, just force it whole LRE if not yet done.
|
||||
if msg[0] not in {LRE, LRO}:
|
||||
msg = LRE + msg
|
||||
|
||||
idx = 0
|
||||
ret = []
|
||||
ln = len(msg)
|
||||
while idx < ln:
|
||||
dlt = 1
|
||||
# # If we find a control char, skip any additional protection!
|
||||
# if msg[idx] in uctrl:
|
||||
# ret.append(msg[idx:])
|
||||
# break
|
||||
# \", \' or \\
|
||||
if idx < (ln - 1) and msg[idx] == '\\' and msg[idx + 1] in "\"\'\\":
|
||||
dlt = 2
|
||||
elif idx < (ln - 1) and msg[idx] == '{':
|
||||
# The whole 'format' syntax...
|
||||
# Coverage of this one is still fairly limited and basic currently.
|
||||
# TODO: support more of the 'format' mini-language (and check how much fmt::format matches with Python's).
|
||||
orig_dlt = dlt
|
||||
valid_format = False
|
||||
|
||||
# {3}, {scale} (positional indicator or named reference)
|
||||
while (idx + dlt) < ln and msg[idx + dlt].isalnum():
|
||||
dlt += 1
|
||||
if (idx + dlt) < ln and msg[idx + dlt] == ":":
|
||||
dlt += 1
|
||||
# {:.4}, {:6d}, ...
|
||||
while (idx + dlt) < ln and msg[idx + dlt] in fmt_format_widthprec:
|
||||
dlt += 1
|
||||
# {:f}, {:s}, ...
|
||||
while (idx + dlt) < ln and msg[idx + dlt] in fmt_format_codes:
|
||||
dlt += 1
|
||||
if (idx + dlt) < ln and msg[idx + dlt] == "}":
|
||||
dlt += 1
|
||||
valid_format = True
|
||||
|
||||
if not valid_format:
|
||||
dlt = orig_dlt
|
||||
# %%
|
||||
elif idx < (ln - 1) and msg[idx] == '%' and msg[idx + 1] == '%':
|
||||
dlt = 2
|
||||
elif idx < (ln - 1) and msg[idx] == '%':
|
||||
# The whole 'printf' syntax...
|
||||
# Not fully covering the format, but most of it, and should cover all Blender usages.
|
||||
orig_dlt = dlt
|
||||
valid_format = False
|
||||
|
||||
# `%x12|` - What is this for actually?
|
||||
if idx < (ln - 2) and msg[idx + 1] in "x" and msg[idx + 2] in printf_format_widthprec:
|
||||
dlt = 2
|
||||
while (idx + dlt) < ln and msg[idx + dlt] in printf_format_widthprec:
|
||||
dlt += 1
|
||||
if (idx + dlt) < ln and msg[idx + dlt] == '|':
|
||||
dlt += 1
|
||||
valid_format = True
|
||||
else:
|
||||
# `%+d, %-40s`, ...
|
||||
while (idx + dlt) < ln and msg[idx + dlt] in printf_format_flags:
|
||||
dlt += 1
|
||||
# `%.4f, %6d`, ...
|
||||
while (idx + dlt) < ln and msg[idx + dlt] in printf_format_widthprec:
|
||||
dlt += 1
|
||||
# `%lld, %zu`, ...
|
||||
while (idx + dlt) < ln and msg[idx + dlt] in printf_format_datasize:
|
||||
dlt += 1
|
||||
# `%s, %d`, ...
|
||||
if (idx + dlt) < ln and msg[idx + dlt] in printf_format_codes:
|
||||
dlt += 1
|
||||
valid_format = True
|
||||
|
||||
if not valid_format:
|
||||
dlt = orig_dlt
|
||||
|
||||
if dlt > 1:
|
||||
ret.append(LRE)
|
||||
ret += msg[idx:idx + dlt]
|
||||
idx += dlt
|
||||
if dlt > 1:
|
||||
ret.append(PDF)
|
||||
|
||||
return "".join(ret)
|
||||
|
||||
|
||||
def log2vis(msgs, settings):
|
||||
"""
|
||||
Globally mimics deprecated fribidi_log2vis.
|
||||
msgs should be an iterable of messages to RTL-process.
|
||||
"""
|
||||
fbd = ctypes.CDLL(settings.FRIBIDI_LIB)
|
||||
|
||||
for msg in msgs:
|
||||
msg = protect_format_seq(msg)
|
||||
|
||||
fbc_str = ctypes.create_unicode_buffer(msg)
|
||||
ln = len(fbc_str) - 1
|
||||
# print(fbc_str.value, ln)
|
||||
btypes = (ctypes.c_int * ln)()
|
||||
embed_lvl = (ctypes.c_uint8 * ln)()
|
||||
pbase_dir = ctypes.c_int(FRIBIDI_PAR_ON)
|
||||
jtypes = (ctypes.c_uint8 * ln)()
|
||||
flags = FRIBIDI_FLAGS_DEFAULT | FRIBIDI_FLAGS_ARABIC
|
||||
|
||||
# Find out direction of each char.
|
||||
fbd.fribidi_get_bidi_types(fbc_str, ln, ctypes.byref(btypes))
|
||||
|
||||
# print(*btypes)
|
||||
|
||||
fbd.fribidi_get_par_embedding_levels(
|
||||
btypes, ln,
|
||||
ctypes.byref(pbase_dir),
|
||||
embed_lvl,
|
||||
)
|
||||
|
||||
# print(*embed_lvl)
|
||||
|
||||
# Joinings for arabic chars.
|
||||
fbd.fribidi_get_joining_types(fbc_str, ln, jtypes)
|
||||
# print(*jtypes)
|
||||
fbd.fribidi_join_arabic(btypes, ln, embed_lvl, jtypes)
|
||||
# print(*jtypes)
|
||||
|
||||
# Final Shaping!
|
||||
fbd.fribidi_shape(flags, embed_lvl, ln, jtypes, fbc_str)
|
||||
|
||||
# print(fbc_str.value)
|
||||
# print(*(ord(c) for c in fbc_str))
|
||||
# And now, the reordering.
|
||||
# Note that here, we expect a single line, so no need to do
|
||||
# fancy things...
|
||||
fbd.fribidi_reorder_line(flags, btypes, ln, 0, pbase_dir, embed_lvl,
|
||||
fbc_str, None)
|
||||
# print(fbc_str.value)
|
||||
# print(*(ord(c) for c in fbc_str))
|
||||
|
||||
yield fbc_str.value
|
||||
@@ -0,0 +1,955 @@
|
||||
# SPDX-FileCopyrightText: 2012-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import os
|
||||
import pickle
|
||||
import re
|
||||
|
||||
try:
|
||||
import enchant
|
||||
except ModuleNotFoundError:
|
||||
enchant = None
|
||||
print("WARNING: No `enchant` python module found, no spell check will happen.")
|
||||
|
||||
|
||||
class SpellChecker:
|
||||
"""
|
||||
A basic spell checker.
|
||||
"""
|
||||
|
||||
# These must be all lower case for comparisons
|
||||
uimsgs = {
|
||||
# OK words
|
||||
"accessor", "accessors",
|
||||
"adaptively", "adaptivity",
|
||||
"al", # et al.
|
||||
"aren", # aren't
|
||||
"betweens", # yuck! in-betweens!
|
||||
"boolean", "booleans",
|
||||
"chamfer",
|
||||
"couldn", # couldn't
|
||||
"customizable",
|
||||
"decrement",
|
||||
"derivate",
|
||||
"deterministically",
|
||||
"doesn", # doesn't
|
||||
"duplications",
|
||||
"effector",
|
||||
"equi", # equi-angular, etc.
|
||||
"eszett", # German Eszett
|
||||
"et", # et al.
|
||||
"fader",
|
||||
"globbing",
|
||||
"guillemet",
|
||||
"gridded",
|
||||
"haptics",
|
||||
"hasn", # hasn't
|
||||
"hetero",
|
||||
"hoc", # ad-hoc
|
||||
"incompressible",
|
||||
"indices",
|
||||
"instantiation",
|
||||
"iridas",
|
||||
"isn", # isn't
|
||||
"iterable",
|
||||
"kyrgyz",
|
||||
"latin",
|
||||
"merchantability",
|
||||
"mille", # Per Mille
|
||||
"mplayer",
|
||||
"ons", # add-ons
|
||||
"pong", # ping pong
|
||||
"procedurals", # Used as noun
|
||||
"recurse",
|
||||
"resumable",
|
||||
"runtimes",
|
||||
"scalable",
|
||||
"shadeless",
|
||||
"shouldn", # shouldn't
|
||||
"smoothen",
|
||||
"spacings",
|
||||
"teleport", "teleporting",
|
||||
"tangency",
|
||||
"vertices",
|
||||
"wasn", # wasn't
|
||||
"zig", "zag",
|
||||
|
||||
# Brands etc.
|
||||
"htc",
|
||||
"huawei",
|
||||
"radeon",
|
||||
"vive",
|
||||
"xbox",
|
||||
|
||||
# Merged words
|
||||
"antialiasing", "antialias",
|
||||
"arcsine", "arccosine", "arctangent",
|
||||
"autoclip",
|
||||
"autocomplete",
|
||||
"autoexec",
|
||||
"autoexecution",
|
||||
"autogenerated",
|
||||
"autolock",
|
||||
"automask", "automasking",
|
||||
"automerge",
|
||||
"autoname",
|
||||
"autopack",
|
||||
"autosave",
|
||||
"autoscale",
|
||||
"autosmooth",
|
||||
"autosplit",
|
||||
"backend", "backends",
|
||||
"backface", "backfacing",
|
||||
"backimage",
|
||||
"backscattered",
|
||||
"bandnoise",
|
||||
"bindcode",
|
||||
"bitdepth",
|
||||
"bitflag", "bitflags",
|
||||
"bitrate",
|
||||
"blackbody",
|
||||
"blendfile",
|
||||
"blendin",
|
||||
"bonesize",
|
||||
"boundbox",
|
||||
"boxpack",
|
||||
"buffersize",
|
||||
"builtin", "builtins",
|
||||
"bytecode",
|
||||
"chunksize",
|
||||
"codebase",
|
||||
"customdata",
|
||||
"dataset", "datasets",
|
||||
"de",
|
||||
"deadzone",
|
||||
"decomposable",
|
||||
"deconstruct",
|
||||
"defocus",
|
||||
"denoise", "denoised", "denoising", "denoiser",
|
||||
"deselect", "deselecting", "deselection",
|
||||
"despill", "despilling",
|
||||
"dirtree",
|
||||
"editcurve",
|
||||
"editmesh",
|
||||
"faceforward",
|
||||
"filebrowser",
|
||||
"filelist",
|
||||
"filename", "filenames",
|
||||
"filepath", "filepaths",
|
||||
"forcefield", "forcefields",
|
||||
"framerange",
|
||||
"frontmost",
|
||||
"fulldome", "fulldomes",
|
||||
"fullscreen",
|
||||
"gamepad",
|
||||
"gridline", "gridlines",
|
||||
"hardlight",
|
||||
"hemi",
|
||||
"hostname",
|
||||
"inbetween",
|
||||
"inscatter", "inscattering",
|
||||
"libdata",
|
||||
"lightcache",
|
||||
"lightgroup", "lightgroups",
|
||||
"lightprobe", "lightprobes",
|
||||
"lightless",
|
||||
"lineset",
|
||||
"linestyle", "linestyles",
|
||||
"localview",
|
||||
"lookup", "lookups",
|
||||
"mathutils",
|
||||
"micropolygon",
|
||||
"midlevel",
|
||||
"midground",
|
||||
"mixdown",
|
||||
"monospaced",
|
||||
"multi",
|
||||
"multifractal",
|
||||
"multiframe",
|
||||
"multilayer",
|
||||
"multipaint",
|
||||
"multires", "multiresolution",
|
||||
"multisampling",
|
||||
"multiscatter",
|
||||
"multitexture",
|
||||
"multithreaded",
|
||||
"multiuser",
|
||||
"multiview",
|
||||
"namespace",
|
||||
"nodetree", "nodetrees",
|
||||
"keyconfig",
|
||||
"offscreen",
|
||||
"online",
|
||||
"playhead",
|
||||
"popup", "popups",
|
||||
"pointcloud",
|
||||
"pre",
|
||||
"precache", "precaching",
|
||||
"precalculate",
|
||||
"precomputing",
|
||||
"prefetch",
|
||||
"prefilter", "prefiltering",
|
||||
"preload",
|
||||
"premultiply", "premultiplied",
|
||||
"prepass",
|
||||
"prepend",
|
||||
"preprocess", "preprocessing", "preprocessor", "preprocessed",
|
||||
"preseek",
|
||||
"preselect", "preselected",
|
||||
"promillage",
|
||||
"pushdown",
|
||||
"raytree",
|
||||
"readonly",
|
||||
"realtime",
|
||||
"recompute", "recomputation",
|
||||
"reinject", "reinjected",
|
||||
"rekey",
|
||||
"relink",
|
||||
"remesh",
|
||||
"reprojection", "reproject", "reprojecting",
|
||||
"resample",
|
||||
"rescale",
|
||||
"resize",
|
||||
"restpose",
|
||||
"resync", "resynced",
|
||||
"retarget", "retargets", "retargeting", "retargeted",
|
||||
"retime", "retimed", "retiming",
|
||||
"rigidbody",
|
||||
"ringnoise",
|
||||
"rolloff",
|
||||
"runtime",
|
||||
"scanline",
|
||||
"screenshot", "screenshots",
|
||||
"seekability",
|
||||
"selfcollision",
|
||||
"shadowbuffer", "shadowbuffers",
|
||||
"singletexture",
|
||||
"softbox",
|
||||
"spellcheck", "spellchecking",
|
||||
"startup",
|
||||
"stateful",
|
||||
"starfield",
|
||||
"studiolight",
|
||||
"subflare", "subflares",
|
||||
"subframe", "subframes",
|
||||
"subclass", "subclasses", "subclassing",
|
||||
"subdirectory", "subdirectories", "subdir", "subdirs",
|
||||
"subitem",
|
||||
"submode",
|
||||
"submodule", "submodules",
|
||||
"subpath",
|
||||
"subsample", "subsamples", "subsampling",
|
||||
"subsize",
|
||||
"substep", "substeps",
|
||||
"substring",
|
||||
"supercompress", "supercompression",
|
||||
"targetless",
|
||||
"textbox", "textboxes",
|
||||
"tilemode",
|
||||
"timestamp", "timestamps",
|
||||
"timestep", "timesteps",
|
||||
"todo",
|
||||
"tradeoff",
|
||||
"un",
|
||||
"unadjust", "unadjusted",
|
||||
"unassign",
|
||||
"unassociate", "unassociated",
|
||||
"unbake",
|
||||
"uncheck",
|
||||
"unclosed",
|
||||
"uncomment",
|
||||
"unculled",
|
||||
"undeformed",
|
||||
"undistort", "undistorted", "undistortion",
|
||||
"uneditable",
|
||||
"ungroup", "ungrouped",
|
||||
"unhandled",
|
||||
"unhide",
|
||||
"unindent",
|
||||
"unitless",
|
||||
"unkeyed",
|
||||
"unlink", "unlinked",
|
||||
"unmute",
|
||||
"unphysical",
|
||||
"unpremultiply",
|
||||
"unprojected",
|
||||
"unprotect",
|
||||
"unreacted",
|
||||
"unreferenced",
|
||||
"unregister", "unregistration", "unregistering",
|
||||
"unselect", "unselected", "unselectable",
|
||||
"unsets",
|
||||
"unshadowed",
|
||||
"unspill",
|
||||
"unstitchable", "unstitch",
|
||||
"unsubdivided", "unsubdivide",
|
||||
"untrusted",
|
||||
"vectorscope",
|
||||
"whitespace", "whitespaces",
|
||||
"worldspace",
|
||||
"workflow",
|
||||
"workspace", "workspaces",
|
||||
|
||||
# Neologisms, slangs
|
||||
"affectable",
|
||||
"animatable",
|
||||
"automagic", "automagically",
|
||||
"blobby",
|
||||
"blockiness", "blocky",
|
||||
"collider", "colliders",
|
||||
"deformer", "deformers",
|
||||
"determinator",
|
||||
"editability",
|
||||
"effectors",
|
||||
"expander",
|
||||
"instancer",
|
||||
"keyer",
|
||||
"lacunarity",
|
||||
"linkable",
|
||||
"numerics",
|
||||
"occluder", "occluders",
|
||||
"overridable",
|
||||
"passepartout",
|
||||
"perspectively",
|
||||
"pixelate",
|
||||
"pointiness",
|
||||
"polycount",
|
||||
"polygonization", "polygonalization", # yuck!
|
||||
"scalings",
|
||||
"selectable", "selectability",
|
||||
"shaper",
|
||||
"smoothen", "smoothening",
|
||||
"spherize", "spherized",
|
||||
"statting", # Running `stat` command, yuck!
|
||||
"stitchable",
|
||||
"symmetrize",
|
||||
"trackability",
|
||||
"transmissivity",
|
||||
"rasterized", "rasterization", "rasterizer",
|
||||
"renderer", "renderers", "renderable", "renderability",
|
||||
|
||||
# Really bad!!!
|
||||
"convertor",
|
||||
"fullscr",
|
||||
|
||||
# Abbreviations
|
||||
"aero",
|
||||
"amb",
|
||||
"anim",
|
||||
"aov",
|
||||
"app",
|
||||
"args", # Arguments
|
||||
"bbox", "bboxes",
|
||||
"bksp", # Backspace
|
||||
"bool",
|
||||
"calc",
|
||||
"cfl",
|
||||
"config", "configs",
|
||||
"const",
|
||||
"coord", "coords",
|
||||
"degr",
|
||||
"diff",
|
||||
"dof",
|
||||
"dupli", "duplis",
|
||||
"eg",
|
||||
"esc",
|
||||
"expr",
|
||||
"fac",
|
||||
"fra",
|
||||
"fract",
|
||||
"frs",
|
||||
"grless",
|
||||
"http",
|
||||
"init",
|
||||
"irr", # Irradiance
|
||||
"kbit", "kb",
|
||||
"lang", "langs",
|
||||
"lclick", "rclick",
|
||||
"lensdist",
|
||||
"loc", "rot", "pos",
|
||||
"lorem",
|
||||
"luma",
|
||||
"mbs", # mouse button 'select'.
|
||||
"mem",
|
||||
"mul", # Multiplicative etc.
|
||||
"multicam",
|
||||
"num",
|
||||
"ok",
|
||||
"orco",
|
||||
"ortho",
|
||||
"pano",
|
||||
"persp",
|
||||
"pref", "prefs",
|
||||
"prev",
|
||||
"param",
|
||||
"premul",
|
||||
"quad", "quads",
|
||||
"quat", "quats",
|
||||
"recalc", "recalcs",
|
||||
"refl",
|
||||
"sce",
|
||||
"sel",
|
||||
"spec",
|
||||
"struct", "structs",
|
||||
"subdiv",
|
||||
"sys",
|
||||
"tex",
|
||||
"texcoord",
|
||||
"tmr", # timer
|
||||
"tri", "tris",
|
||||
"udim", "udims",
|
||||
"upres", # Upresolution
|
||||
"usd",
|
||||
"uv", "uvs", "uvw", "uw", "uvmap",
|
||||
"ve",
|
||||
"vec",
|
||||
"vel", # velocity!
|
||||
"vert", "verts",
|
||||
"vis",
|
||||
"vram",
|
||||
"xor",
|
||||
"xyz", "xzy", "yxz", "yzx", "zxy", "zyx",
|
||||
"xy", "xz", "yx", "yz", "zx", "zy",
|
||||
|
||||
# General computer/science terms
|
||||
"affine",
|
||||
"albedo",
|
||||
"anamorphic",
|
||||
"anisotropic", "anisotropy",
|
||||
"arcminute", "arcminutes",
|
||||
"arcsecond", "arcseconds",
|
||||
"autokey",
|
||||
"bimanual", # OpenXR?
|
||||
"bitangent",
|
||||
"boid", "boids",
|
||||
"ceil",
|
||||
"centum", # From 'centum weight'
|
||||
"compressibility",
|
||||
"coplanar",
|
||||
"curvilinear",
|
||||
"dekameter", "dekameters",
|
||||
"equiangular",
|
||||
"equisolid",
|
||||
"euler", "eulers",
|
||||
"eumelanin",
|
||||
"fribidi",
|
||||
"gettext",
|
||||
"hashable",
|
||||
"hotspot",
|
||||
"hydrostatic",
|
||||
"interocular",
|
||||
"intrinsics",
|
||||
"irradiance",
|
||||
"isosurface",
|
||||
"jitter", "jittering", "jittered",
|
||||
"keymap", "keymaps",
|
||||
"lambertian",
|
||||
"laplacian",
|
||||
"metadata",
|
||||
"microwatt", "microwatts",
|
||||
"microflake",
|
||||
"milliwatt", "milliwatts",
|
||||
"msgfmt",
|
||||
"nand", "xnor",
|
||||
"nanowatt", "nanowatts",
|
||||
"normals",
|
||||
"numpad",
|
||||
"octahedral",
|
||||
"octree",
|
||||
"omnidirectional",
|
||||
"opengl",
|
||||
"openmp",
|
||||
"parametrization",
|
||||
"pheomelanin",
|
||||
"photoreceptor",
|
||||
"picometer", "picometers",
|
||||
"poly",
|
||||
"polyline", "polylines",
|
||||
"probabilistically",
|
||||
"pulldown", "pulldowns",
|
||||
"quadratically",
|
||||
"quantized",
|
||||
"quartic",
|
||||
"quaternion", "quaternions",
|
||||
"quintic",
|
||||
"reallocations",
|
||||
"samplerate",
|
||||
"sandboxed",
|
||||
"sawtooth",
|
||||
"scrollback",
|
||||
"scrollbar",
|
||||
"scroller",
|
||||
"searchable",
|
||||
"spacebar",
|
||||
"subtractive",
|
||||
"superellipse",
|
||||
"thumbstick",
|
||||
"tooltip", "tooltips",
|
||||
"touchpad", "trackpad",
|
||||
"trilinear",
|
||||
"triquadratic",
|
||||
"tuple",
|
||||
"unicode",
|
||||
"viewport", "viewports",
|
||||
"viscoelastic",
|
||||
"vorticity",
|
||||
"waveform", "waveforms",
|
||||
"wildcard", "wildcards",
|
||||
"wintab", # Some Windows tablet API
|
||||
|
||||
# General computer graphics terms
|
||||
"anaglyph",
|
||||
"bezier", "beziers",
|
||||
"bicubic",
|
||||
"bilinear",
|
||||
"bindpose",
|
||||
"binormal",
|
||||
"blackpoint", "whitepoint",
|
||||
"blendshape", "blendshapes", # USD slang :(
|
||||
"blinn",
|
||||
"bokeh",
|
||||
"catadioptric",
|
||||
"centroid",
|
||||
"chroma",
|
||||
"chrominance",
|
||||
"clearcoat",
|
||||
"codec", "codecs",
|
||||
"codepoint",
|
||||
"colorspace",
|
||||
"compositing",
|
||||
"crossfade",
|
||||
"cubemap", "cubemaps",
|
||||
"cuda",
|
||||
"deinterlace",
|
||||
"dropoff",
|
||||
"duotone",
|
||||
"dv",
|
||||
"eigenvectors",
|
||||
"emissive",
|
||||
"equirectangular",
|
||||
"fader",
|
||||
"filmlike",
|
||||
"fisheye",
|
||||
"framerate",
|
||||
"gimbal",
|
||||
"grayscale",
|
||||
"icosahedron",
|
||||
"icosphere",
|
||||
"illuminant", # CIE illuminant D65
|
||||
"inpaint",
|
||||
"kerning",
|
||||
"lightmap",
|
||||
"linearlight",
|
||||
"lossless", "lossy",
|
||||
"luminance",
|
||||
"mantaflow",
|
||||
"matcap",
|
||||
"microfacet",
|
||||
"midtones",
|
||||
"mipmap", "mipmaps", "mip",
|
||||
"ngon", "ngons",
|
||||
"ntsc",
|
||||
"nurb", "nurbs",
|
||||
"perlin",
|
||||
"phong",
|
||||
"photorealistic",
|
||||
"pinlight",
|
||||
"posterize",
|
||||
"primvar", "primvars", # USD slang :(
|
||||
"qi",
|
||||
"radiosity",
|
||||
"raycast", "raycasting",
|
||||
"raymarching",
|
||||
"raytrace", "raytracing", "raytraced",
|
||||
"refractions",
|
||||
"remesher", "remeshing", "remesh",
|
||||
"renderfarm",
|
||||
"retopology",
|
||||
"scanfill",
|
||||
"shader", "shaders",
|
||||
"shadowmap", "shadowmaps",
|
||||
"softlight",
|
||||
"specular", "specularity",
|
||||
"spillmap",
|
||||
"sobel",
|
||||
"stereoscopy",
|
||||
"subpixel",
|
||||
"surfel", "surfels", # Surface Element
|
||||
"texel",
|
||||
"timecode",
|
||||
"tonemap",
|
||||
"toon",
|
||||
"transmissive",
|
||||
"uvproject",
|
||||
"uvtile", # Form UDIM
|
||||
"vividlight",
|
||||
"volumetrics",
|
||||
"voronoi",
|
||||
"voxel", "voxels",
|
||||
"vsync",
|
||||
"vulkan",
|
||||
"wireframe", "wireframes",
|
||||
"xforms", # USD slang :(
|
||||
"zmask",
|
||||
"ztransp",
|
||||
|
||||
# Blender terms
|
||||
"audaspace",
|
||||
"azone", # action zone
|
||||
"backwire",
|
||||
"bbone",
|
||||
"bdata",
|
||||
"bendy", # bones
|
||||
"bmesh",
|
||||
"breakdowner",
|
||||
"bspline",
|
||||
"bweight",
|
||||
"colorband",
|
||||
"crazyspace",
|
||||
"datablock", "datablocks",
|
||||
"despeckle",
|
||||
"depsgraph",
|
||||
"dopesheet",
|
||||
"dupliface", "duplifaces",
|
||||
"dupliframe", "dupliframes",
|
||||
"dupliobject", "dupliob",
|
||||
"dupligroup",
|
||||
"duplivert",
|
||||
"dyntopo",
|
||||
"editbone",
|
||||
"editmode",
|
||||
"eevee",
|
||||
"fcurve", "fcurves",
|
||||
"fedge", "fedges",
|
||||
"filmic",
|
||||
"fluidsim",
|
||||
"freestyle",
|
||||
"enum", "enums",
|
||||
"gizmogroup",
|
||||
"gon", "gons", # N-GON(s)
|
||||
"gpencil",
|
||||
"idcol",
|
||||
"ipos",
|
||||
"keyframe", "keyframes", "keyframing", "keyframed",
|
||||
"lookdev",
|
||||
"luminocity",
|
||||
"mathvis",
|
||||
"metaball", "metaballs", "mball",
|
||||
"metaelement", "metaelements",
|
||||
"metastrip", "metastrips",
|
||||
"movieclip", "movieclips",
|
||||
"mpoly",
|
||||
"mtex",
|
||||
"nabla",
|
||||
"navmesh",
|
||||
"outliner",
|
||||
"overscan",
|
||||
"paintmap", "paintmaps",
|
||||
"pointclouds",
|
||||
"polygroup", "polygroups",
|
||||
"poselib",
|
||||
"pushpull",
|
||||
"qe", # keys...
|
||||
"shaderfx", "shaderfxs",
|
||||
"shapekey", "shapekeys",
|
||||
"shrinkfatten",
|
||||
"shrinkwrap",
|
||||
"softbody",
|
||||
"srna",
|
||||
"stucci",
|
||||
"subdiv",
|
||||
"subtype",
|
||||
"sunsky",
|
||||
"tessface", "tessfaces",
|
||||
"texface",
|
||||
"timeline", "timelines",
|
||||
"tmpact", # sigh...
|
||||
"tosphere",
|
||||
"uilist",
|
||||
"userpref",
|
||||
"vcol", "vcols",
|
||||
"vgroup", "vgroups",
|
||||
"vinterlace",
|
||||
"vse",
|
||||
"wasd", "wasdqe", # keys...
|
||||
"wetmap", "wetmaps",
|
||||
"wpaint",
|
||||
"uvwarp",
|
||||
|
||||
# UOC (Ugly Operator Categories)
|
||||
"cachefile",
|
||||
"paintcurve",
|
||||
"ptcache",
|
||||
"dpaint",
|
||||
|
||||
# Algorithm/library/tools names
|
||||
"ashikhmin", # Ashikhmin-Shirley
|
||||
"arsloe", # Texel-Marsen-Arsloe
|
||||
"beckmann",
|
||||
"blackman", # Blackman-Harris
|
||||
"blosc",
|
||||
"burley", # Christensen-Burley
|
||||
"butterworth",
|
||||
"catmull",
|
||||
"catrom",
|
||||
"chiang",
|
||||
"chebychev",
|
||||
"conrady", # Brown-Conrady
|
||||
"courant",
|
||||
"cryptomatte", "crypto",
|
||||
"devlin",
|
||||
"embree",
|
||||
"gmp",
|
||||
"gltfpack",
|
||||
"hosek",
|
||||
"kutta",
|
||||
"kuwahara",
|
||||
"lennard",
|
||||
"marsen", # Texel-Marsen-Arsloe
|
||||
"mikktspace",
|
||||
"minkowski",
|
||||
"minnaert",
|
||||
"mises", # von Mises-Fisher
|
||||
"moskowitz", # Pierson-Moskowitz
|
||||
"musgrave",
|
||||
"nayar",
|
||||
"netravali",
|
||||
"nishita",
|
||||
"ogawa",
|
||||
"oren",
|
||||
"peucker", # Ramer-Douglas-Peucker
|
||||
"pierson", # Pierson-Moskowitz
|
||||
"preetham",
|
||||
"prewitt",
|
||||
"ramer", # Ramer-Douglas-Peucker
|
||||
"reinhard",
|
||||
"runge",
|
||||
"sobol",
|
||||
"verlet",
|
||||
"von", # von Mises-Fisher
|
||||
"wilkie",
|
||||
"worley",
|
||||
|
||||
# Acronyms
|
||||
"aa", "msaa",
|
||||
"acescg", # ACEScg color space.
|
||||
"ao",
|
||||
"aov", "aovs",
|
||||
"api",
|
||||
"apic", # Affine Particle-In-Cell
|
||||
"asc", "cdl",
|
||||
"ascii",
|
||||
"atrac",
|
||||
"avx",
|
||||
"bsdf", "bsdfs",
|
||||
"bssrdf",
|
||||
"bt", # BT.1886 2.4 Exponent EOTF
|
||||
"bw",
|
||||
"ccd",
|
||||
"cie", # CIE XYZ color space
|
||||
"cmd",
|
||||
"cmos",
|
||||
"cpus",
|
||||
"ctrl",
|
||||
"cw", "ccw",
|
||||
"dci", # DCI-P3 D65
|
||||
"dev",
|
||||
"dls",
|
||||
"djv",
|
||||
"dpi",
|
||||
"dvar",
|
||||
"dx",
|
||||
"eo",
|
||||
"eotf", # BT.1886 2.4 Exponent EOTF
|
||||
"ewa",
|
||||
"fh",
|
||||
"fk",
|
||||
"fov",
|
||||
"fft",
|
||||
"futura",
|
||||
"fx",
|
||||
"gfx",
|
||||
"ggx",
|
||||
"gl",
|
||||
"glsl",
|
||||
"gpl",
|
||||
"gpu", "gpus",
|
||||
"hc",
|
||||
"hdc",
|
||||
"hdr", "hdri", "hdris",
|
||||
"hh", "mm", "ss", "ff", # `hh:mm:ss:ff` time-code.
|
||||
"hpg", # Intel Xe-HPG architecture
|
||||
"hsv", "hsva", "hsl",
|
||||
"id",
|
||||
"iec", # sRGB IEC 61966-2-1
|
||||
"ies",
|
||||
"ior",
|
||||
"itu",
|
||||
"jonswap",
|
||||
"lfe",
|
||||
"lhs",
|
||||
"lmb", "mmb", "rmb",
|
||||
"lscm",
|
||||
"lx", # Lux light unit
|
||||
"kb",
|
||||
"mis",
|
||||
"mocap",
|
||||
"msgid", "msgids",
|
||||
"mux",
|
||||
"ndof",
|
||||
"pbr", # Physically Based Rendering
|
||||
"ppc",
|
||||
"precisa",
|
||||
"px",
|
||||
"qmc",
|
||||
"rdna",
|
||||
"rdp",
|
||||
"rgb", "rgba",
|
||||
"ris",
|
||||
"rhs",
|
||||
"rpp", # EEVEE ray-tracing?
|
||||
"rv",
|
||||
"sdf",
|
||||
"sdl",
|
||||
"sdls",
|
||||
"sl",
|
||||
"smpte",
|
||||
"ssao",
|
||||
"ssr",
|
||||
"svn",
|
||||
"tma",
|
||||
"ui",
|
||||
"unix",
|
||||
"uuid", "uid",
|
||||
"vbo", "vbos",
|
||||
"vfx",
|
||||
"vmm",
|
||||
"vr",
|
||||
"wxyz",
|
||||
"xform",
|
||||
"xr",
|
||||
"ycc", "ycca",
|
||||
"yrgb",
|
||||
"yuv", "yuva",
|
||||
|
||||
# Blender acronyms
|
||||
"bli",
|
||||
"bpy",
|
||||
"bvh",
|
||||
"dbvt",
|
||||
"dop", # BLI K-DOP BVH
|
||||
"ik",
|
||||
"nla",
|
||||
"py",
|
||||
"qbvh",
|
||||
"rna",
|
||||
"rvo",
|
||||
"simd",
|
||||
"sph",
|
||||
"svbvh",
|
||||
|
||||
# Files types/formats
|
||||
"aac",
|
||||
"avi",
|
||||
"attrac",
|
||||
"autocad",
|
||||
"autodesk",
|
||||
"bmp",
|
||||
"btx",
|
||||
"cineon",
|
||||
"dpx",
|
||||
"dwaa",
|
||||
"dwab",
|
||||
"dxf",
|
||||
"eps",
|
||||
"exr",
|
||||
"fbx",
|
||||
"fbxnode",
|
||||
"ffmpeg",
|
||||
"flac",
|
||||
"gltf",
|
||||
"gprim", # From USD.
|
||||
"gzip",
|
||||
"ico",
|
||||
"jpg", "jpeg", "jpegs",
|
||||
"json",
|
||||
"lightwave",
|
||||
"lzw",
|
||||
"matroska",
|
||||
"mdd",
|
||||
"mkv",
|
||||
"mpeg", "mjpeg",
|
||||
"mtl",
|
||||
"ogg",
|
||||
"openjpeg",
|
||||
"osl",
|
||||
"oso",
|
||||
"pcm",
|
||||
"piz",
|
||||
"png", "pngs",
|
||||
"po",
|
||||
"quicktime",
|
||||
"rle",
|
||||
"sgi",
|
||||
"stl",
|
||||
"svg",
|
||||
"targa", "tga",
|
||||
"tiff",
|
||||
"theora",
|
||||
"usdz",
|
||||
"vdb",
|
||||
"vorbis",
|
||||
"vp9",
|
||||
"wav",
|
||||
"webm",
|
||||
"xiph",
|
||||
"xml",
|
||||
"xna",
|
||||
"xvid",
|
||||
}
|
||||
|
||||
_valid_before = "(?<=[\\s*'\"`])|(?<=[a-zA-Z][/-])|(?<=^)"
|
||||
_valid_after = "(?=[\\s'\"`.!?,;:])|(?=[/-]\\s*[a-zA-Z])|(?=$)"
|
||||
_valid_words = "(?:{})(?:(?:[A-Z]+[a-z]*)|[A-Z]*|[a-z]*)(?:{})".format(_valid_before, _valid_after)
|
||||
_split_words = re.compile(_valid_words).findall
|
||||
|
||||
@classmethod
|
||||
def split_words(cls, text):
|
||||
return [w for w in cls._split_words(text) if w]
|
||||
|
||||
def __init__(self, settings, lang="en_US"):
|
||||
self.settings = settings
|
||||
self.dict_spelling = enchant.Dict(lang) if enchant else None
|
||||
self.cache = set(self.uimsgs)
|
||||
|
||||
cache = self.settings.SPELL_CACHE
|
||||
if cache and os.path.exists(cache):
|
||||
with open(cache, 'rb') as f:
|
||||
self.cache |= set(pickle.load(f))
|
||||
|
||||
def __del__(self):
|
||||
cache = self.settings.SPELL_CACHE
|
||||
if cache and os.path.exists(cache):
|
||||
with open(cache, 'wb') as f:
|
||||
pickle.dump(self.cache, f)
|
||||
|
||||
def check(self, txt):
|
||||
ret = []
|
||||
|
||||
if txt in self.cache:
|
||||
return ret
|
||||
|
||||
for w in self.split_words(txt):
|
||||
w_lower = w.lower()
|
||||
if w_lower in self.cache:
|
||||
continue
|
||||
if self.dict_spelling and not self.dict_spelling.check(w):
|
||||
ret.append((w, self.dict_spelling.suggest(w)))
|
||||
else:
|
||||
self.cache.add(w_lower)
|
||||
|
||||
if not ret:
|
||||
self.cache.add(txt)
|
||||
|
||||
return ret
|
||||
@@ -0,0 +1,538 @@
|
||||
# SPDX-FileCopyrightText: 2015-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Populate a template file (POT format currently) from Blender RNA/py/C data.
|
||||
# Note: This script is meant to be used from inside Blender!
|
||||
|
||||
import os
|
||||
|
||||
import bpy
|
||||
from mathutils import (
|
||||
Euler,
|
||||
Matrix,
|
||||
Vector,
|
||||
)
|
||||
|
||||
|
||||
OBJECT_TYPES_RENDER = {'MESH', 'CURVE', 'SURFACE', 'META', 'FONT'}
|
||||
|
||||
|
||||
def ids_nolib(bids):
|
||||
return (bid for bid in bids if not bid.library)
|
||||
|
||||
|
||||
def ids_nolib_with_preview(bids):
|
||||
return (bid for bid in bids if (not bid.library and bid.preview))
|
||||
|
||||
|
||||
def rna_backup_gen(data, include_props=None, exclude_props=None, root=()):
|
||||
# only writable properties...
|
||||
for p in data.bl_rna.properties:
|
||||
pid = p.identifier
|
||||
if pid in {"rna_type", "original"}:
|
||||
continue
|
||||
path = root + (pid,)
|
||||
if include_props is not None and path not in include_props:
|
||||
continue
|
||||
if exclude_props is not None and path in exclude_props:
|
||||
continue
|
||||
val = getattr(data, pid)
|
||||
if val is not None and p.type == 'POINTER':
|
||||
# recurse!
|
||||
yield from rna_backup_gen(val, include_props, exclude_props, root=path)
|
||||
elif data.is_property_readonly(pid):
|
||||
continue
|
||||
else:
|
||||
yield path, val
|
||||
|
||||
|
||||
def rna_backup_restore(data, backup):
|
||||
for path, val in backup:
|
||||
dt = data
|
||||
for pid in path[:-1]:
|
||||
dt = getattr(dt, pid)
|
||||
setattr(dt, path[-1], val)
|
||||
|
||||
|
||||
def do_previews(do_objects, do_collections, do_scenes, do_data_intern):
|
||||
import collections
|
||||
|
||||
# Helpers.
|
||||
RenderContext = collections.namedtuple("RenderContext", (
|
||||
"scene", "world", "camera", "light", "camera_data", "light_data", "image", # All those are names!
|
||||
"backup_scene", "backup_world", "backup_camera", "backup_light", "backup_camera_data", "backup_light_data",
|
||||
))
|
||||
|
||||
RENDER_PREVIEW_SIZE = bpy.app.render_preview_size
|
||||
|
||||
def render_context_create(engine, objects_ignored):
|
||||
if engine == '__SCENE':
|
||||
backup_scene, backup_world, backup_camera, backup_light, backup_camera_data, backup_light_data = [()] * 6
|
||||
scene = bpy.context.window.scene
|
||||
exclude_props = {("world",), ("camera",), ("tool_settings",), ("preview",), ("asset_data",),
|
||||
("collection", "asset_data",), ("render", "bake", "image_settings", "linear_colorspace_settings",)}
|
||||
backup_scene = tuple(rna_backup_gen(scene, exclude_props=exclude_props))
|
||||
world = scene.world
|
||||
camera = scene.camera
|
||||
if camera:
|
||||
camera_data = camera.data
|
||||
else:
|
||||
backup_camera, backup_camera_data = [None] * 2
|
||||
camera_data = bpy.data.cameras.new("TEMP_preview_render_camera")
|
||||
camera = bpy.data.objects.new("TEMP_preview_render_camera", camera_data)
|
||||
camera.rotation_euler = Euler((1.1635528802871704, 0.0, 0.7853981852531433), 'XYZ') # (66.67, 0, 45)
|
||||
scene.camera = camera
|
||||
scene.collection.objects.link(camera)
|
||||
# TODO: add light if none found in scene?
|
||||
light = None
|
||||
light_data = None
|
||||
else:
|
||||
backup_scene, backup_world, backup_camera, backup_light, backup_camera_data, backup_light_data = [None] * 6
|
||||
|
||||
scene = bpy.data.scenes.new("TEMP_preview_render_scene")
|
||||
world = bpy.data.worlds.new("TEMP_preview_render_world")
|
||||
camera_data = bpy.data.cameras.new("TEMP_preview_render_camera")
|
||||
camera = bpy.data.objects.new("TEMP_preview_render_camera", camera_data)
|
||||
light_data = bpy.data.lights.new("TEMP_preview_render_light", 'SPOT')
|
||||
light = bpy.data.objects.new("TEMP_preview_render_light", light_data)
|
||||
|
||||
objects_ignored.add((camera.name, light.name))
|
||||
|
||||
scene.world = world
|
||||
|
||||
camera.rotation_euler = Euler((1.1635528802871704, 0.0, 0.7853981852531433), 'XYZ') # (66.67, 0, 45)
|
||||
scene.camera = camera
|
||||
scene.collection.objects.link(camera)
|
||||
|
||||
light.rotation_euler = Euler((0.7853981852531433, 0.0, 1.7453292608261108), 'XYZ') # (45, 0, 100)
|
||||
light_data.spot_size = 1.0471975803375244 # 60
|
||||
scene.collection.objects.link(light)
|
||||
|
||||
scene.render.engine = 'CYCLES'
|
||||
scene.render.film_transparent = True
|
||||
# TODO: define Cycles world?
|
||||
|
||||
scene.render.image_settings.media_type = 'IMAGE'
|
||||
scene.render.image_settings.file_format = 'PNG'
|
||||
scene.render.image_settings.color_depth = '8'
|
||||
scene.render.image_settings.color_mode = 'RGBA'
|
||||
scene.render.image_settings.compression = 25
|
||||
scene.render.resolution_x = RENDER_PREVIEW_SIZE
|
||||
scene.render.resolution_y = RENDER_PREVIEW_SIZE
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.filepath = os.path.join(bpy.app.tempdir, 'TEMP_preview_render.png')
|
||||
scene.render.use_overwrite = True
|
||||
scene.render.use_stamp = False
|
||||
scene.render.threads_mode = 'AUTO'
|
||||
|
||||
image = bpy.data.images.new("TEMP_render_image", RENDER_PREVIEW_SIZE, RENDER_PREVIEW_SIZE, alpha=True)
|
||||
image.source = 'FILE'
|
||||
image.filepath = scene.render.filepath
|
||||
|
||||
return RenderContext(
|
||||
scene.name, world.name if world else None, camera.name, light.name if light else None,
|
||||
camera_data.name, light_data.name if light_data else None, image.name,
|
||||
backup_scene, backup_world, backup_camera, backup_light, backup_camera_data, backup_light_data,
|
||||
)
|
||||
|
||||
def render_context_delete(render_context):
|
||||
# We use try/except blocks here to avoid crash, too much things can go wrong, and we want to leave the current
|
||||
# .blend as clean as possible!
|
||||
success = True
|
||||
|
||||
scene = bpy.data.scenes[render_context.scene, None]
|
||||
try:
|
||||
if render_context.backup_scene is None:
|
||||
scene.world = None
|
||||
scene.camera = None
|
||||
if render_context.camera:
|
||||
scene.collection.objects.unlink(bpy.data.objects[render_context.camera, None])
|
||||
if render_context.light:
|
||||
scene.collection.objects.unlink(bpy.data.objects[render_context.light, None])
|
||||
bpy.data.scenes.remove(scene, do_unlink=True)
|
||||
scene = None
|
||||
else:
|
||||
rna_backup_restore(scene, render_context.backup_scene)
|
||||
except Exception as ex:
|
||||
print("ERROR:", ex)
|
||||
success = False
|
||||
|
||||
if render_context.world is not None:
|
||||
try:
|
||||
world = bpy.data.worlds[render_context.world, None]
|
||||
if render_context.backup_world is None:
|
||||
if scene is not None:
|
||||
scene.world = None
|
||||
world.user_clear()
|
||||
bpy.data.worlds.remove(world)
|
||||
else:
|
||||
rna_backup_restore(world, render_context.backup_world)
|
||||
except Exception as ex:
|
||||
print("ERROR:", ex)
|
||||
success = False
|
||||
|
||||
if render_context.camera:
|
||||
try:
|
||||
camera = bpy.data.objects[render_context.camera, None]
|
||||
if render_context.backup_camera is None:
|
||||
if scene is not None:
|
||||
scene.camera = None
|
||||
scene.collection.objects.unlink(camera)
|
||||
camera.user_clear()
|
||||
bpy.data.objects.remove(camera)
|
||||
bpy.data.cameras.remove(bpy.data.cameras[render_context.camera_data, None])
|
||||
else:
|
||||
rna_backup_restore(camera, render_context.backup_camera)
|
||||
rna_backup_restore(bpy.data.cameras[render_context.camera_data, None],
|
||||
render_context.backup_camera_data)
|
||||
except Exception as ex:
|
||||
print("ERROR:", ex)
|
||||
success = False
|
||||
|
||||
if render_context.light:
|
||||
try:
|
||||
light = bpy.data.objects[render_context.light, None]
|
||||
if render_context.backup_light is None:
|
||||
if scene is not None:
|
||||
scene.collection.objects.unlink(light)
|
||||
light.user_clear()
|
||||
bpy.data.objects.remove(light)
|
||||
bpy.data.lights.remove(bpy.data.lights[render_context.light_data, None])
|
||||
else:
|
||||
rna_backup_restore(light, render_context.backup_light)
|
||||
rna_backup_restore(bpy.data.lights[render_context.light_data,
|
||||
None], render_context.backup_light_data)
|
||||
except Exception as ex:
|
||||
print("ERROR:", ex)
|
||||
success = False
|
||||
|
||||
try:
|
||||
image = bpy.data.images[render_context.image, None]
|
||||
image.user_clear()
|
||||
bpy.data.images.remove(image)
|
||||
except Exception as ex:
|
||||
print("ERROR:", ex)
|
||||
success = False
|
||||
|
||||
return success
|
||||
|
||||
def object_bbox_merge(bbox, ob, ob_space, offset_matrix):
|
||||
# Take collections instances into account (including linked one in this case).
|
||||
if ob.type == 'EMPTY' and ob.instance_type == 'COLLECTION':
|
||||
grp_objects = tuple((ob.name, ob.library.filepath if ob.library else None)
|
||||
for ob in ob.instance_collection.all_objects)
|
||||
if (len(grp_objects) == 0):
|
||||
ob_bbox = ob.bound_box
|
||||
else:
|
||||
coords = objects_bbox_calc(ob_space, grp_objects,
|
||||
Matrix.Translation(ob.instance_collection.instance_offset).inverted())
|
||||
ob_bbox = ((coords[0], coords[1], coords[2]), (coords[21], coords[22], coords[23]))
|
||||
elif ob.bound_box:
|
||||
ob_bbox = ob.bound_box
|
||||
else:
|
||||
ob_bbox = ((-ob.scale.x, -ob.scale.y, -ob.scale.z), (ob.scale.x, ob.scale.y, ob.scale.z))
|
||||
|
||||
for v in ob_bbox:
|
||||
v = offset_matrix @ Vector(v) if offset_matrix is not None else Vector(v)
|
||||
v = ob_space.matrix_world.inverted() @ ob.matrix_world @ v
|
||||
if bbox[0].x > v.x:
|
||||
bbox[0].x = v.x
|
||||
if bbox[0].y > v.y:
|
||||
bbox[0].y = v.y
|
||||
if bbox[0].z > v.z:
|
||||
bbox[0].z = v.z
|
||||
if bbox[1].x < v.x:
|
||||
bbox[1].x = v.x
|
||||
if bbox[1].y < v.y:
|
||||
bbox[1].y = v.y
|
||||
if bbox[1].z < v.z:
|
||||
bbox[1].z = v.z
|
||||
|
||||
def objects_bbox_calc(camera, objects, offset_matrix):
|
||||
bbox = (Vector((1e24, 1e24, 1e24)), Vector((-1e24, -1e24, -1e24)))
|
||||
for obname, libpath in objects:
|
||||
ob = bpy.data.objects[obname, libpath]
|
||||
object_bbox_merge(bbox, ob, camera, offset_matrix)
|
||||
# Our bbox has been generated in camera local space, bring it back in world one
|
||||
bbox[0][:] = camera.matrix_world @ bbox[0]
|
||||
bbox[1][:] = camera.matrix_world @ bbox[1]
|
||||
cos = (
|
||||
bbox[0].x, bbox[0].y, bbox[0].z,
|
||||
bbox[0].x, bbox[0].y, bbox[1].z,
|
||||
bbox[0].x, bbox[1].y, bbox[0].z,
|
||||
bbox[0].x, bbox[1].y, bbox[1].z,
|
||||
bbox[1].x, bbox[0].y, bbox[0].z,
|
||||
bbox[1].x, bbox[0].y, bbox[1].z,
|
||||
bbox[1].x, bbox[1].y, bbox[0].z,
|
||||
bbox[1].x, bbox[1].y, bbox[1].z,
|
||||
)
|
||||
return cos
|
||||
|
||||
def preview_render_do(render_context, item_container, item_name, objects, offset_matrix=None):
|
||||
# Unused.
|
||||
# scene = bpy.data.scenes[render_context.scene, None]
|
||||
if objects is not None:
|
||||
camera = bpy.data.objects[render_context.camera, None]
|
||||
light = bpy.data.objects[render_context.light, None] if render_context.light is not None else None
|
||||
cos = objects_bbox_calc(camera, objects, offset_matrix)
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
loc, _ortho_scale = camera.camera_fit_coords(depsgraph, cos)
|
||||
camera.location = loc
|
||||
# Set camera clipping accordingly to computed bbox.
|
||||
min_dist = 1e24
|
||||
max_dist = -1e24
|
||||
for co in zip(*(iter(cos),) * 3):
|
||||
dist = (Vector(co) - loc).length
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
if dist > max_dist:
|
||||
max_dist = dist
|
||||
camera.data.clip_start = min_dist / 2
|
||||
camera.data.clip_end = max_dist * 2
|
||||
if light:
|
||||
loc, _ortho_scale = light.camera_fit_coords(depsgraph, cos)
|
||||
light.location = loc
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
image = bpy.data.images[render_context.image, None]
|
||||
item = getattr(bpy.data, item_container)[item_name, None]
|
||||
image.reload()
|
||||
preview = item.preview_ensure()
|
||||
preview.image_size = (RENDER_PREVIEW_SIZE, RENDER_PREVIEW_SIZE)
|
||||
preview.image_pixels_float[:] = image.pixels
|
||||
|
||||
# And now, main code!
|
||||
do_save = True
|
||||
|
||||
if do_data_intern:
|
||||
bpy.ops.wm.previews_clear(id_type={'SHADING'})
|
||||
bpy.ops.wm.previews_ensure()
|
||||
|
||||
render_contexts = {}
|
||||
|
||||
objects_ignored = set()
|
||||
collections_ignored = set()
|
||||
|
||||
prev_scenename = bpy.context.window.scene.name
|
||||
|
||||
if do_objects:
|
||||
prev_shown = {ob.name: ob.hide_render for ob in ids_nolib(bpy.data.objects)}
|
||||
for ob in ids_nolib(bpy.data.objects):
|
||||
if ob in objects_ignored:
|
||||
continue
|
||||
ob.hide_render = True
|
||||
for root in ids_nolib(bpy.data.objects):
|
||||
if root.name in objects_ignored:
|
||||
continue
|
||||
if root.type not in OBJECT_TYPES_RENDER:
|
||||
continue
|
||||
objects = ((root.name, None),)
|
||||
|
||||
render_context = render_contexts.get('CYCLES', None)
|
||||
if render_context is None:
|
||||
render_context = render_context_create('CYCLES', objects_ignored)
|
||||
render_contexts['CYCLES'] = render_context
|
||||
|
||||
scene = bpy.data.scenes[render_context.scene, None]
|
||||
bpy.context.window.scene = scene
|
||||
|
||||
for obname, libpath in objects:
|
||||
ob = bpy.data.objects[obname, libpath]
|
||||
if obname not in scene.objects:
|
||||
scene.collection.objects.link(ob)
|
||||
ob.hide_render = False
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
preview_render_do(render_context, "objects", root.name, objects)
|
||||
|
||||
# XXX Hyper Super Uber Suspicious Hack!
|
||||
# Without this, on windows build, script excepts with following message:
|
||||
# Traceback (most recent call last):
|
||||
# File "<string>", line 1, in <module>
|
||||
# File "<string>", line 451, in <module>
|
||||
# File "<string>", line 443, in main
|
||||
# File "<string>", line 327, in do_previews
|
||||
# OverflowError: Python int too large to convert to C long
|
||||
# ... :(
|
||||
scene = bpy.data.scenes[render_context.scene, None]
|
||||
for obname, libpath in objects:
|
||||
ob = bpy.data.objects[obname, libpath]
|
||||
scene.collection.objects.unlink(ob)
|
||||
ob.hide_render = True
|
||||
|
||||
for ob in ids_nolib(bpy.data.objects):
|
||||
is_rendered = prev_shown.get(ob.name, ...)
|
||||
if is_rendered is not ...:
|
||||
ob.hide_render = is_rendered
|
||||
|
||||
if do_collections:
|
||||
for grp in ids_nolib(bpy.data.collections):
|
||||
if grp.name in collections_ignored:
|
||||
continue
|
||||
# Here too, we do want to keep linked objects members of local collection...
|
||||
objects = tuple((ob.name, ob.library.filepath if ob.library else None) for ob in grp.objects)
|
||||
|
||||
render_context = render_contexts.get('CYCLES', None)
|
||||
if render_context is None:
|
||||
render_context = render_context_create('CYCLES', objects_ignored)
|
||||
render_contexts['CYCLES'] = render_context
|
||||
|
||||
scene = bpy.data.scenes[render_context.scene, None]
|
||||
bpy.context.window.scene = scene
|
||||
|
||||
bpy.ops.object.collection_instance_add(collection=grp.name)
|
||||
grp_ob = next((
|
||||
ob for ob in scene.objects
|
||||
if ob.instance_collection and ob.instance_collection.name == grp.name
|
||||
))
|
||||
grp_obname = grp_ob.name
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
offset_matrix = Matrix.Translation(grp.instance_offset).inverted()
|
||||
|
||||
preview_render_do(render_context, "collections", grp.name, objects, offset_matrix)
|
||||
|
||||
scene = bpy.data.scenes[render_context.scene, None]
|
||||
scene.collection.objects.unlink(bpy.data.objects[grp_obname, None])
|
||||
|
||||
bpy.context.window.scene = bpy.data.scenes[prev_scenename, None]
|
||||
for render_context in render_contexts.values():
|
||||
if not render_context_delete(render_context):
|
||||
do_save = False # Do not save file if something went wrong here, we could 'pollute' it with temp data...
|
||||
|
||||
if do_scenes:
|
||||
for scene in ids_nolib(bpy.data.scenes):
|
||||
has_camera = scene.camera is not None
|
||||
bpy.context.window.scene = scene
|
||||
render_context = render_context_create('__SCENE', objects_ignored)
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
objects = None
|
||||
if not has_camera:
|
||||
# We had to add a temp camera, now we need to place it to see interesting objects!
|
||||
objects = tuple((ob.name, ob.library.filepath if ob.library else None) for ob in scene.objects
|
||||
if (not ob.hide_render) and (ob.type in OBJECT_TYPES_RENDER))
|
||||
|
||||
preview_render_do(render_context, "scenes", scene.name, objects)
|
||||
|
||||
if not render_context_delete(render_context):
|
||||
do_save = False
|
||||
|
||||
bpy.context.window.scene = bpy.data.scenes[prev_scenename, None]
|
||||
if do_save:
|
||||
print("Saving {:s}...".format(bpy.data.filepath))
|
||||
try:
|
||||
bpy.ops.wm.save_mainfile()
|
||||
except Exception as ex:
|
||||
# Might fail in some odd cases, like e.g. in regression files we have `glsl/ram_glsl.blend` which
|
||||
# references an nonexistent texture. Better not break in this case, just spit error to console.
|
||||
print("ERROR:", ex)
|
||||
else:
|
||||
print(
|
||||
"*NOT* Saving {:s}, because some error(s) happened while deleting temp render data...".format(
|
||||
bpy.data.filepath,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def do_clear_previews(do_objects, do_collections, do_scenes, do_data_intern):
|
||||
if do_data_intern:
|
||||
bpy.ops.wm.previews_clear(id_type={'SHADING'})
|
||||
|
||||
if do_objects:
|
||||
for ob in ids_nolib_with_preview(bpy.data.objects):
|
||||
ob.preview.image_size = (0, 0)
|
||||
|
||||
if do_collections:
|
||||
for grp in ids_nolib_with_preview(bpy.data.collections):
|
||||
grp.preview.image_size = (0, 0)
|
||||
|
||||
if do_scenes:
|
||||
for scene in ids_nolib_with_preview(bpy.data.scenes):
|
||||
scene.preview.image_size = (0, 0)
|
||||
|
||||
print("Saving {:s}...".format(bpy.data.filepath))
|
||||
bpy.ops.wm.save_mainfile()
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
import bpy
|
||||
except ImportError:
|
||||
print("This script must run from inside blender")
|
||||
return
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
# Get rid of Blender args!
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Use Blender to generate previews for currently open Blender file's items.",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--clear',
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="Clear previews instead of generating them.",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no_backups',
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="Do not generate a backup .blend1 file when saving processed ones.",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no_scenes',
|
||||
default=True,
|
||||
action="store_false",
|
||||
help="Do not generate/clear previews for scene IDs.",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no_collections',
|
||||
default=True,
|
||||
action="store_false",
|
||||
help="Do not generate/clear previews for collection IDs.",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no_objects',
|
||||
default=True,
|
||||
action="store_false",
|
||||
help="Do not generate/clear previews for object IDs.",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no_data_intern',
|
||||
default=True,
|
||||
action="store_false",
|
||||
help="Do not generate/clear previews for mat/tex/image/etc. IDs (those handled by core Blender code).",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
orig_save_version = bpy.context.preferences.filepaths.save_version
|
||||
if args.no_backups:
|
||||
bpy.context.preferences.filepaths.save_version = 0
|
||||
elif orig_save_version < 1:
|
||||
bpy.context.preferences.filepaths.save_version = 1
|
||||
|
||||
if args.clear:
|
||||
print("clear!")
|
||||
do_clear_previews(do_objects=args.no_objects, do_collections=args.no_collections, do_scenes=args.no_scenes,
|
||||
do_data_intern=args.no_data_intern)
|
||||
else:
|
||||
print("render!")
|
||||
do_previews(do_objects=args.no_objects, do_collections=args.no_collections, do_scenes=args.no_scenes,
|
||||
do_data_intern=args.no_data_intern)
|
||||
|
||||
# Not really necessary, but better be consistent.
|
||||
bpy.context.preferences.filepaths.save_version = orig_save_version
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n\n *** Running {:s} *** \n".format(__file__))
|
||||
print(" *** Blend file {:s} *** \n".format(bpy.data.filepath))
|
||||
main()
|
||||
bpy.ops.wm.quit_blender()
|
||||
76
blender-5.2.0/scripts/modules/_bl_rna_utils/data_path.py
Normal file
76
blender-5.2.0/scripts/modules/_bl_rna_utils/data_path.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# SPDX-FileCopyrightText: 2021-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"property_definition_from_data_path",
|
||||
"decompose_data_path",
|
||||
)
|
||||
|
||||
|
||||
class _TokenizeDataPath:
|
||||
"""
|
||||
Class to split up tokens of a data-path.
|
||||
|
||||
Note that almost all access generates new objects with additional paths,
|
||||
with the exception of iteration which is the intended way to access the resulting data."""
|
||||
__slots__ = (
|
||||
"data_path",
|
||||
)
|
||||
|
||||
def __init__(self, attrs):
|
||||
self.data_path = attrs
|
||||
|
||||
def __getattr__(self, attr):
|
||||
return _TokenizeDataPath(self.data_path + ((".{:s}".format(attr)),))
|
||||
|
||||
def __getitem__(self, key):
|
||||
return _TokenizeDataPath(self.data_path + (("[{!r}]".format(key)),))
|
||||
|
||||
def __call__(self, *args, **kw):
|
||||
value_str = ", ".join([
|
||||
val for val in (
|
||||
", ".join(repr(value) for value in args),
|
||||
", ".join(["{:s}={!r}".format(key, value) for key, value in kw.items()]),
|
||||
) if val])
|
||||
return _TokenizeDataPath(self.data_path + ('({:s})'.format(value_str), ))
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.data_path)
|
||||
|
||||
|
||||
def decompose_data_path(data_path):
|
||||
"""
|
||||
Return the components of a data path split into a list.
|
||||
"""
|
||||
ns = {"base": _TokenizeDataPath(())}
|
||||
return list(eval("base" + data_path, ns, ns))
|
||||
|
||||
|
||||
def property_definition_from_data_path(base, data_path):
|
||||
"""
|
||||
Return an RNA property definition from an object and a data path.
|
||||
|
||||
In Blender this is often used with ``context`` as the base and a
|
||||
path that it references, for example ``.space_data.lock_camera``.
|
||||
"""
|
||||
data = decompose_data_path(data_path)
|
||||
while data and (not data[-1].startswith(".")):
|
||||
data.pop()
|
||||
|
||||
if (not data) or (not data[-1].startswith(".")) or (len(data) < 2):
|
||||
return None
|
||||
|
||||
data_path_head = "".join(data[:-1])
|
||||
data_path_tail = data[-1]
|
||||
|
||||
value_head = eval("base" + data_path_head)
|
||||
value_head_rna = getattr(value_head, "bl_rna", None)
|
||||
if value_head_rna is None:
|
||||
return None
|
||||
|
||||
value_tail = value_head.bl_rna.properties.get(data_path_tail[1:])
|
||||
if not value_tail:
|
||||
return None
|
||||
|
||||
return value_tail
|
||||
@@ -0,0 +1,54 @@
|
||||
# SPDX-FileCopyrightText: 2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"open_external_editor",
|
||||
)
|
||||
|
||||
|
||||
def open_external_editor(filepath, line, column, /):
|
||||
# Internal Python implementation for `TEXT_OT_jump_to_file_at_point`.
|
||||
# Returning a non-empty string represents an error, an empty string for success.
|
||||
import shlex
|
||||
import subprocess
|
||||
from string import Template
|
||||
from bpy import context
|
||||
from bpy.app.translations import pgettext_rpt as rpt_
|
||||
|
||||
text_editor = context.preferences.filepaths.text_editor
|
||||
text_editor_args = context.preferences.filepaths.text_editor_args
|
||||
|
||||
# The caller should check this.
|
||||
assert text_editor
|
||||
|
||||
if not text_editor_args:
|
||||
return rpt_(
|
||||
"Provide text editor argument format in File Paths/Applications Preferences, "
|
||||
"see input field tool-tip for more information",
|
||||
)
|
||||
|
||||
if "$filepath" not in text_editor_args:
|
||||
return rpt_("Text Editor Args Format must contain $filepath")
|
||||
|
||||
args = [text_editor]
|
||||
template_vars = {
|
||||
"filepath": filepath,
|
||||
"line": line + 1,
|
||||
"column": column + 1,
|
||||
"line0": line,
|
||||
"column0": column,
|
||||
}
|
||||
|
||||
try:
|
||||
args.extend([Template(arg).substitute(**template_vars) for arg in shlex.split(text_editor_args)])
|
||||
except Exception as ex:
|
||||
return rpt_("Exception parsing template: {!r}").format(ex)
|
||||
|
||||
try:
|
||||
# With `check=True` if `process.returncode != 0` an exception will be raised.
|
||||
subprocess.run(args, check=True)
|
||||
except Exception as ex:
|
||||
return rpt_("Exception running external editor: {!r}").format(ex)
|
||||
|
||||
return ""
|
||||
21
blender-5.2.0/scripts/modules/_bl_ui_utils/layout.py
Normal file
21
blender-5.2.0/scripts/modules/_bl_ui_utils/layout.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# SPDX-FileCopyrightText: 2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import contextlib
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def operator_context(layout, op_context):
|
||||
"""Context manager that temporarily overrides the operator context.
|
||||
|
||||
>>> with operator_context(layout, 'INVOKE_REGION_CHANNELS'):
|
||||
... layout.operator("anim.channels_delete")
|
||||
"""
|
||||
|
||||
orig_context = layout.operator_context
|
||||
layout.operator_context = op_context
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
layout.operator_context = orig_context
|
||||
234
blender-5.2.0/scripts/modules/_blendfile_header.py
Normal file
234
blender-5.2.0/scripts/modules/_blendfile_header.py
Normal file
@@ -0,0 +1,234 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
'''
|
||||
This module contains utility classes for reading headers in .blend files.
|
||||
|
||||
This is a pure Python implementation of the corresponding C++ code in Blender
|
||||
in BLO_core_blend_header.hh and BLO_core_bhead.hh.
|
||||
'''
|
||||
|
||||
import os
|
||||
import struct
|
||||
import typing
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class BlendHeaderError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class BHead4:
|
||||
code: bytes
|
||||
len: int
|
||||
old: int
|
||||
SDNAnr: int
|
||||
nr: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmallBHead8:
|
||||
code: bytes
|
||||
len: int
|
||||
old: int
|
||||
SDNAnr: int
|
||||
nr: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class LargeBHead8:
|
||||
code: bytes
|
||||
SDNAnr: int
|
||||
old: int
|
||||
len: int
|
||||
nr: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockHeaderStruct:
|
||||
# Binary format of the encoded header.
|
||||
struct: struct.Struct
|
||||
# Corresponding Python type for retrieving block header values.
|
||||
type: typing.Type[typing.Union[BHead4, SmallBHead8, LargeBHead8]]
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return self.struct.size
|
||||
|
||||
def parse(self, data: bytes) -> typing.Union[BHead4, SmallBHead8, LargeBHead8]:
|
||||
return self.type(*self.struct.unpack(data))
|
||||
|
||||
|
||||
class BlendFileHeader:
|
||||
"""
|
||||
BlendFileHeader represents the first 12-17 bytes of a blend file.
|
||||
|
||||
It contains information about the hardware architecture, which is relevant
|
||||
to the structure of the rest of the file.
|
||||
"""
|
||||
|
||||
# Always 'BLENDER'.
|
||||
magic: bytes
|
||||
# Currently always 0 or 1.
|
||||
file_format_version: int
|
||||
# Either 4 or 8.
|
||||
pointer_size: int
|
||||
# Endianness of values stored in the file.
|
||||
is_little_endian: bool
|
||||
# Blender version the file has been written with.
|
||||
# The last two digits are the minor version. So 280 is 2.80.
|
||||
version: int
|
||||
|
||||
def __init__(self, file: typing.IO[bytes]) -> None:
|
||||
file.seek(0, os.SEEK_SET)
|
||||
|
||||
bytes_0_6 = file.read(7)
|
||||
if bytes_0_6 != b'BLENDER':
|
||||
raise BlendHeaderError("invalid first bytes {!r}".format(bytes_0_6))
|
||||
self.magic = bytes_0_6
|
||||
|
||||
byte_7 = file.read(1)
|
||||
is_legacy_header = byte_7 in (b'_', b'-')
|
||||
if is_legacy_header:
|
||||
self.file_format_version = 0
|
||||
if byte_7 == b'_':
|
||||
self.pointer_size = 4
|
||||
elif byte_7 == b'-':
|
||||
self.pointer_size = 8
|
||||
else:
|
||||
raise BlendHeaderError("invalid pointer size {!r}".format(byte_7))
|
||||
byte_8 = file.read(1)
|
||||
if byte_8 == b'v':
|
||||
self.is_little_endian = True
|
||||
elif byte_8 == b'V':
|
||||
self.is_little_endian = False
|
||||
else:
|
||||
raise BlendHeaderError("invalid endian indicator {!r}".format(byte_8))
|
||||
bytes_9_11 = file.read(3)
|
||||
self.version = int(bytes_9_11)
|
||||
else:
|
||||
byte_8 = file.read(1)
|
||||
header_size = int(byte_7 + byte_8)
|
||||
if header_size != 17:
|
||||
raise BlendHeaderError("unknown file header size {:d}".format(header_size))
|
||||
byte_9 = file.read(1)
|
||||
if byte_9 != b'-':
|
||||
raise BlendHeaderError("invalid file header")
|
||||
self.pointer_size = 8
|
||||
byte_10_11 = file.read(2)
|
||||
self.file_format_version = int(byte_10_11)
|
||||
if self.file_format_version != 1:
|
||||
raise BlendHeaderError("unsupported file format version {:d}".format(self.file_format_version))
|
||||
byte_12 = file.read(1)
|
||||
if byte_12 != b'v':
|
||||
raise BlendHeaderError("invalid file header")
|
||||
self.is_little_endian = True
|
||||
byte_13_16 = file.read(4)
|
||||
self.version = int(byte_13_16)
|
||||
|
||||
def create_block_header_struct(self) -> BlockHeaderStruct:
|
||||
assert self.file_format_version in (0, 1)
|
||||
endian_str = b'<' if self.is_little_endian else b'>'
|
||||
if self.file_format_version == 1:
|
||||
header_struct = struct.Struct(b''.join((
|
||||
endian_str,
|
||||
# LargeBHead8.code
|
||||
b'4s',
|
||||
# LargeBHead8.SDNAnr
|
||||
b'i',
|
||||
# LargeBHead8.old
|
||||
b'Q',
|
||||
# LargeBHead8.len
|
||||
b'q',
|
||||
# LargeBHead8.nr
|
||||
b'q',
|
||||
)))
|
||||
return BlockHeaderStruct(header_struct, LargeBHead8)
|
||||
|
||||
if self.pointer_size == 4:
|
||||
header_struct = struct.Struct(b''.join((
|
||||
endian_str,
|
||||
# BHead4.code
|
||||
b'4s',
|
||||
# BHead4.len
|
||||
b'i',
|
||||
# BHead4.old
|
||||
b'I',
|
||||
# BHead4.SDNAnr
|
||||
b'i',
|
||||
# BHead4.nr
|
||||
b'i',
|
||||
)))
|
||||
return BlockHeaderStruct(header_struct, BHead4)
|
||||
|
||||
assert self.pointer_size == 8
|
||||
header_struct = struct.Struct(b''.join((
|
||||
endian_str,
|
||||
# SmallBHead8.code
|
||||
b'4s',
|
||||
# SmallBHead8.len
|
||||
b'i',
|
||||
# SmallBHead8.old
|
||||
b'Q',
|
||||
# SmallBHead8.SDNAnr
|
||||
b'i',
|
||||
# SmallBHead8.nr
|
||||
b'i',
|
||||
)))
|
||||
return BlockHeaderStruct(header_struct, SmallBHead8)
|
||||
|
||||
|
||||
class BlockHeader:
|
||||
"""
|
||||
A .blend file consists of a sequence of blocks whereby each block has a header.
|
||||
This class can parse a header block in a specific .blend file.
|
||||
|
||||
Note the binary representation of this header is different for different files.
|
||||
This class provides a unified interface for these underlying representations.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"code",
|
||||
"size",
|
||||
"addr_old",
|
||||
"sdna_index",
|
||||
"count",
|
||||
)
|
||||
|
||||
# Indicates the type of the block. See BLO_CODE_* in BLO_core_bhead.hh.
|
||||
code: bytes
|
||||
# Number of bytes in the block.
|
||||
size: int
|
||||
# Old pointer/identifier of the block.
|
||||
addr_old: int
|
||||
# DNA struct index of the data in the block.
|
||||
sdna_index: int
|
||||
# Number of DNA structures in the block.
|
||||
count: int
|
||||
|
||||
def __init__(self, file: typing.IO[bytes], block_header_struct: BlockHeaderStruct) -> None:
|
||||
data = file.read(block_header_struct.size)
|
||||
|
||||
if len(data) != block_header_struct.size:
|
||||
if len(data) != 8:
|
||||
raise BlendHeaderError("invalid block header size")
|
||||
legacy_endb = struct.Struct(b'4sI')
|
||||
endb_header = legacy_endb.unpack(data)
|
||||
if endb_header[0] != b'ENDB':
|
||||
raise BlendHeaderError("invalid block header")
|
||||
self.code = b'ENDB'
|
||||
self.size = 0
|
||||
self.addr_old = 0
|
||||
self.sdna_index = 0
|
||||
self.count = 0
|
||||
return
|
||||
|
||||
header = block_header_struct.parse(data)
|
||||
self.code = header.code.partition(b'\0')[0]
|
||||
self.size = header.len
|
||||
self.addr_old = header.old
|
||||
self.sdna_index = header.SDNAnr
|
||||
self.count = header.nr
|
||||
3
blender-5.2.0/scripts/modules/_bpy_internal/__init__.py
Normal file
3
blender-5.2.0/scripts/modules/_bpy_internal/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
42
blender-5.2.0/scripts/modules/_bpy_internal/addons/cli.py
Normal file
42
blender-5.2.0/scripts/modules/_bpy_internal/addons/cli.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
Implementation of blender's command line ``--addons`` argument,
|
||||
e.g. ``--addons a,b,c`` to enable add-ons.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"set_from_cli",
|
||||
)
|
||||
|
||||
|
||||
def set_from_cli(addons_as_string):
|
||||
from addon_utils import (
|
||||
check,
|
||||
check_extension,
|
||||
enable,
|
||||
extensions_refresh,
|
||||
)
|
||||
addon_modules = addons_as_string.split(",")
|
||||
addon_modules_extensions = [m for m in addon_modules if check_extension(m)]
|
||||
addon_modules_extensions_has_failure = False
|
||||
|
||||
if addon_modules_extensions:
|
||||
extensions_refresh(
|
||||
ensure_wheels=True,
|
||||
addon_modules_pending=addon_modules_extensions,
|
||||
)
|
||||
|
||||
for m in addon_modules:
|
||||
if check(m)[1] is False:
|
||||
if enable(m, persistent=True, refresh_handled=True) is None:
|
||||
if check_extension(m):
|
||||
addon_modules_extensions_has_failure = True
|
||||
|
||||
# Re-calculate wheels if any extensions failed to be enabled.
|
||||
if addon_modules_extensions_has_failure:
|
||||
extensions_refresh(
|
||||
ensure_wheels=True,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
@@ -0,0 +1,27 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# The function below is (un)registered from scripts/addons_core/bl_pkg/__init__.py:
|
||||
def asset_listing_main(args: list[str]) -> int:
|
||||
"""Run the `blender -c asset_listing` CLI command.
|
||||
|
||||
This is late-importing the cli module, so that it (and its
|
||||
dependencies) are only imported when actually used.
|
||||
"""
|
||||
import traceback
|
||||
from . import cli
|
||||
|
||||
try:
|
||||
cli.main(args)
|
||||
except SystemExit as ex:
|
||||
if isinstance(ex.code, int):
|
||||
return ex.code
|
||||
return 2
|
||||
except BaseException:
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
return 0
|
||||
@@ -0,0 +1,671 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"download_asset_file",
|
||||
"downloader_status",
|
||||
"DownloadStatus",
|
||||
]
|
||||
|
||||
from collections.abc import Callable
|
||||
import dataclasses
|
||||
import enum
|
||||
import logging
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
from _bpy_internal.http import downloader as http_dl
|
||||
from _bpy_internal.assets.remote_library.listing_downloader import RemoteAssetListingLocator
|
||||
from _bpy_internal.assets.remote_library import hashing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Preview images will NOT be downloaded if they already exist on disk AND their
|
||||
# timestamp is younger than this age.
|
||||
PREVIEW_DOWNLOAD_AGE_THRESHOLD_SEC = 7 * 24 * 3600 # 1 week
|
||||
|
||||
_asset_downloaders: dict[str, AssetDownloader] = {}
|
||||
_preview_downloaders: dict[str, AssetDownloader] = {}
|
||||
|
||||
|
||||
def download_asset_file(
|
||||
asset_library_url: str,
|
||||
asset_library_local_path: Path,
|
||||
asset_url: str,
|
||||
asset_hash: str,
|
||||
save_to: Path) -> str:
|
||||
"""Download an asset file to a file on disk.
|
||||
|
||||
:param asset_library_url: Root URL of the remote asset library. Used as an
|
||||
identifier of this library (to create a downloader per library), as well
|
||||
as for resolving relative URLs.
|
||||
|
||||
:param asset_library_local_path: Root path of the local asset cache. Used to
|
||||
resolve relative `save_to` paths, but also to find the HTTP metadata
|
||||
cache for this asset library (for conditional downloads).
|
||||
|
||||
:param asset_url: the URL to download. Can be absolute or relative to the
|
||||
asset library URL. If it is an empty string, the `save_to` path is used
|
||||
as the URL.
|
||||
:param asset_hash: the hash of the asset file, will be appended to the URL.
|
||||
|
||||
:param save_to: the path on disk where to download to. While the download is
|
||||
pending, ".part" will be appended to the filename. When the download
|
||||
finishes successfully, it is renamed to the final path.
|
||||
|
||||
:returns: the final URL that was queued for downloading.
|
||||
"""
|
||||
try:
|
||||
downloader = _asset_downloaders[asset_library_url]
|
||||
assert downloader.local_path == asset_library_local_path, (
|
||||
"This code assumes that remote asset libraries do not move on the local disk"
|
||||
)
|
||||
except KeyError:
|
||||
downloader = AssetDownloader(
|
||||
asset_library_url,
|
||||
asset_library_local_path,
|
||||
reporter=AssetReporter(asset_library_url=asset_library_url),
|
||||
on_queue_empty_callback=on_asset_download_queue_empty,
|
||||
)
|
||||
downloader.start()
|
||||
_asset_downloaders[asset_library_url] = downloader
|
||||
|
||||
# Construct the URL if not given explicitly.
|
||||
if not asset_url:
|
||||
if save_to.is_absolute():
|
||||
relative_path = save_to.relative_to(asset_library_local_path)
|
||||
else:
|
||||
relative_path = save_to
|
||||
asset_url = urllib.parse.quote(relative_path.as_posix())
|
||||
|
||||
# Include the hash in the URL, and download the asset.
|
||||
download_url = hashing.url((asset_url, asset_hash))
|
||||
full_url = downloader.download_asset_file(download_url, save_to)
|
||||
return full_url
|
||||
|
||||
|
||||
def download_preview(
|
||||
asset_library_url: str,
|
||||
asset_library_local_path: Path,
|
||||
preview_url: str,
|
||||
preview_hash: str,
|
||||
dst_filepath: Path) -> None:
|
||||
"""Download an asset preview to a file on disk.
|
||||
|
||||
:param asset_library_url: Root URL of the remote asset library. Used as an
|
||||
identifier of this library (to create a downloader per library), as well
|
||||
as for resolving relative URLs.
|
||||
|
||||
:param asset_library_local_path: Root path of the local asset cache. Used to
|
||||
resolve relative `save_to` paths, but also to find the HTTP metadata
|
||||
cache for this asset library (for conditional downloads).
|
||||
|
||||
:param preview_url: the URL to download. Can be absolute or relative.
|
||||
:param preview_hash: the hash of the thumbnail, will be appended to the URL.
|
||||
|
||||
:param dst_filepath: the path on disk where to download to. While the
|
||||
download is pending, ".part" will be appended to the filename. When the
|
||||
download finishes successfully, it is renamed to the final path.
|
||||
"""
|
||||
import time
|
||||
|
||||
# Check if the file exists and is new enough. If it is, don't bother the server.
|
||||
try:
|
||||
stat = dst_filepath.stat()
|
||||
except FileNotFoundError:
|
||||
pass # Fine, something new to download.
|
||||
else:
|
||||
# File exists, let's see if it's young enough to use as-is.
|
||||
age_in_seconds = time.time() - stat.st_mtime
|
||||
if age_in_seconds < PREVIEW_DOWNLOAD_AGE_THRESHOLD_SEC:
|
||||
# The local file is still fresh, just pretend we just downloaded it.
|
||||
bpy.types.WindowManager.asset_library_status_ping_loaded_new_preview(str(dst_filepath))
|
||||
return
|
||||
|
||||
try:
|
||||
downloader = _preview_downloaders[asset_library_url]
|
||||
assert downloader.local_path == asset_library_local_path, (
|
||||
"This code assumes that remote asset libraries do not move on the local disk"
|
||||
)
|
||||
except KeyError:
|
||||
downloader = AssetDownloader(
|
||||
asset_library_url,
|
||||
asset_library_local_path,
|
||||
reporter=PreviewReporter(),
|
||||
on_queue_empty_callback=None,
|
||||
)
|
||||
downloader.start()
|
||||
_preview_downloaders[asset_library_url] = downloader
|
||||
|
||||
# Include the hash in the URL, and download the preview.
|
||||
download_url = hashing.url((preview_url, preview_hash))
|
||||
downloader.download_asset_file(download_url, dst_filepath)
|
||||
|
||||
|
||||
def cancel_download(asset_library_url: str, full_asset_url: str) -> None:
|
||||
"""Cancel a running/queued asset download.
|
||||
|
||||
Cancelling a URL that has already been fully downloaded, or one that was never
|
||||
queued is a no-op.
|
||||
|
||||
:param asset_library_url: Root URL of the remote asset library. Used as an
|
||||
identifier of this library (to create a downloader per library).
|
||||
Contrary to the download function, this is NOT used to resolve relative
|
||||
URLs.
|
||||
:param full_asset_url: the URL that's queued for download. MUST be the final
|
||||
URL as returned by download_asset_file().
|
||||
"""
|
||||
|
||||
try:
|
||||
downloader = _asset_downloaders[asset_library_url]
|
||||
except KeyError:
|
||||
# No downloader could mean that the cancel came in just a millisecond
|
||||
# too late, and the download was already finished.
|
||||
return
|
||||
|
||||
downloader.cancel_download(full_asset_url)
|
||||
|
||||
|
||||
def cancel_download_all_assets() -> None:
|
||||
"""Cancel all active/queued downloads of all assets.
|
||||
|
||||
This shuts down all asset downloaders, effectively cancelling all their downloads.
|
||||
"""
|
||||
|
||||
for downloader in _asset_downloaders.values():
|
||||
downloader.cancel_and_shutdown()
|
||||
|
||||
|
||||
def downloader_status(asset_library_url: str) -> DownloadStatus:
|
||||
"""Returns the asset downloader status.
|
||||
|
||||
Raises a KeyError if there never was a downloader for this URL.
|
||||
"""
|
||||
return _asset_downloaders[asset_library_url].status
|
||||
|
||||
|
||||
def on_asset_download_queue_empty() -> None:
|
||||
"""Called by the asset downloader when its download queue emptied."""
|
||||
if any_asset_downloading():
|
||||
return
|
||||
bpy.types.WindowManager.asset_library_status_ping_finished_download_queue()
|
||||
|
||||
|
||||
def any_asset_downloading() -> bool:
|
||||
"""Returns true if there is any downloader currently downloading assets."""
|
||||
return any(
|
||||
downloader.status == DownloadStatus.DOWNLOADING
|
||||
for downloader in _asset_downloaders.values()
|
||||
)
|
||||
|
||||
|
||||
class DownloadStatus(enum.Enum):
|
||||
IDLE = 'idle'
|
||||
DOWNLOADING = 'downloading'
|
||||
|
||||
FINISHED = 'finished'
|
||||
"""The downloader has downloaded everything that was queued.
|
||||
|
||||
Note: this does NOT mean that all downloads were perfect. It just means that
|
||||
there were no exceptions raised.
|
||||
"""
|
||||
|
||||
FAILED = 'failed'
|
||||
"""Unexpected exceptions occurred."""
|
||||
|
||||
CANCELLED = 'cancelled'
|
||||
"""There still were pending downloads when the downloader shut down."""
|
||||
|
||||
|
||||
class AssetDownloader:
|
||||
"""Downloader for asset files & their thumbnails."""
|
||||
|
||||
_locator: RemoteAssetListingLocator
|
||||
_bg_downloader: http_dl.BackgroundDownloader | None
|
||||
_reporter: http_dl.DownloadReporter
|
||||
_num_assets_pending: int
|
||||
|
||||
type QueueEmptyCallback = Callable[[], None]
|
||||
_on_queue_empty_callback: QueueEmptyCallback | None
|
||||
"""Called when the download queue became empty."""
|
||||
|
||||
_status: DownloadStatus
|
||||
_error_message: str
|
||||
"""An error message to show to the user.
|
||||
|
||||
Should be set on errors to communicate a message to users. Calling report()
|
||||
with 'ERROR' as the level will set this to the given message.
|
||||
"""
|
||||
|
||||
_DOWNLOAD_POLL_INTERVAL: float = 0.01
|
||||
"""How often the background download process is polled, in seconds.
|
||||
|
||||
Each 'poll' involves sending queued messages back & forth between the main
|
||||
Blender process and the background download process.
|
||||
"""
|
||||
|
||||
_HTTP_METHOD = "GET"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
remote_url: str,
|
||||
local_path: Path | str,
|
||||
*,
|
||||
reporter: http_dl.DownloadReporter,
|
||||
on_queue_empty_callback: QueueEmptyCallback | None,
|
||||
) -> None:
|
||||
"""Create a downloader for assets of a specific asset library.
|
||||
|
||||
:param remote_url: Base URL of the remote asset library server.
|
||||
|
||||
:param local_path: The directory to download the index files to.
|
||||
|
||||
:param on_download_done_callback: called with one parameter (this
|
||||
AssetDownloader) when a file finished downloading and was put
|
||||
in its final location, ready to be picked up by the asset system.
|
||||
"""
|
||||
self._locator = RemoteAssetListingLocator(remote_url, local_path)
|
||||
self._num_assets_pending = 0
|
||||
self._reporter = reporter
|
||||
self._on_queue_empty_callback = on_queue_empty_callback
|
||||
|
||||
self._status = DownloadStatus.IDLE
|
||||
self._error_message = ""
|
||||
|
||||
# Work around a limitation of Blender, see bug report #139720 for details.
|
||||
self.on_timer_event = self.on_timer_event # type: ignore[method-assign]
|
||||
|
||||
self._http_metadata_provider = http_dl.MetadataProviderFilesystem(
|
||||
cache_location=self._locator.http_metadata_cache_location,
|
||||
)
|
||||
|
||||
self._bg_downloader = None
|
||||
|
||||
def _create_bg_downloader(self) -> None:
|
||||
self._bg_downloader = http_dl.BackgroundDownloader(
|
||||
options=http_dl.DownloaderOptions(
|
||||
metadata_provider=self._http_metadata_provider,
|
||||
timeout=300,
|
||||
http_headers={
|
||||
'X-Blender': "{:d}.{:d}".format(*bpy.app.version),
|
||||
},
|
||||
),
|
||||
on_callback_error=self._on_callback_error,
|
||||
)
|
||||
|
||||
# These are called in order. Doing things this way ensures that self._reporter.download_finished() is called for
|
||||
# every individual download, and after that our own function is called. That means that the
|
||||
# self._on_queue_empty_callback() function is called _after_ the individual downloads.
|
||||
#
|
||||
# Swapping this order would mean self._on_queue_empty_callback() is called _before_ the last call to
|
||||
# self._reporter.download_finished(), which would be confusing.
|
||||
self._bg_downloader.add_reporter(self._reporter)
|
||||
self._bg_downloader.add_reporter(self)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "{!s}(remote_url={!r}, local_path={!r})".format(
|
||||
type(self),
|
||||
self._locator.remote_url,
|
||||
self._locator.local_path,
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the background process."""
|
||||
if not self._bg_downloader:
|
||||
self._create_bg_downloader()
|
||||
assert self._bg_downloader
|
||||
self._bg_downloader.start()
|
||||
|
||||
# Register the timer for periodic message passing between the main and
|
||||
# background processes.
|
||||
if not bpy.app.timers.is_registered(self.on_timer_event):
|
||||
bpy.app.timers.register(
|
||||
self.on_timer_event,
|
||||
first_interval=self._DOWNLOAD_POLL_INTERVAL,
|
||||
persistent=True,
|
||||
)
|
||||
# Double-check the registration worked, see #139720 for details.
|
||||
assert bpy.app.timers.is_registered(self.on_timer_event)
|
||||
|
||||
def download_asset_file(self, asset_url: str, save_to: Path) -> str:
|
||||
"""Download an asset or preview file to a local file.
|
||||
|
||||
Returns the URL that was queued. This is different than the given URL
|
||||
when the latter is relative.
|
||||
"""
|
||||
|
||||
# If the downloader was shut down, start it up again.
|
||||
if not self._bg_downloader:
|
||||
self.start()
|
||||
|
||||
self._status = DownloadStatus.DOWNLOADING
|
||||
url = self._queue_download(asset_url, save_to)
|
||||
return url
|
||||
|
||||
def cancel_download(self, full_asset_url: str) -> None:
|
||||
"""Cancel downloading a URL.
|
||||
|
||||
If the URL was never queued, or it has already been downloaded,
|
||||
this is a no-op.
|
||||
"""
|
||||
if not self._bg_downloader:
|
||||
return
|
||||
|
||||
logger.info("cancelling download of %s", full_asset_url)
|
||||
http_req_descr = http_dl.RequestDescription(self._HTTP_METHOD, full_asset_url)
|
||||
self._bg_downloader.cancel_download(http_req_descr)
|
||||
|
||||
def _shutdown_if_done(self) -> None:
|
||||
if self._num_assets_pending > 0:
|
||||
return
|
||||
|
||||
is_done = self._bg_downloader is None or self._bg_downloader.all_downloads_done
|
||||
if not is_done:
|
||||
return
|
||||
|
||||
# Done downloading everything, let's shut down.
|
||||
self._status = DownloadStatus.FINISHED
|
||||
|
||||
if self._on_queue_empty_callback is not None:
|
||||
# Call the callback _after_ setting the status, so that when
|
||||
# Blender is pinged about this, it can see it's finished.
|
||||
self._on_queue_empty_callback()
|
||||
|
||||
# TODO: delay this for a few minutes, so that we don't need a new
|
||||
# background process for every asset.
|
||||
self.shutdown()
|
||||
|
||||
def _on_callback_error(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
exception: Exception) -> None:
|
||||
logger.exception(
|
||||
"exception while handling downloaded file ({!r}, saved to {!r})".format(
|
||||
http_req_descr, local_file))
|
||||
self.report({'ERROR'}, "Resource download had an issue, download aborted")
|
||||
self._status = DownloadStatus.FAILED
|
||||
self.shutdown()
|
||||
|
||||
def _queue_download(self, asset_url: str, download_to_path: Path | str) -> str:
|
||||
"""Queue up this download.
|
||||
|
||||
Returns the URL of the download, and the path to which it will be downloaded.
|
||||
"""
|
||||
remote_url = urllib.parse.urljoin(self._locator.remote_url, asset_url)
|
||||
download_to_path = self._locator.local_path / download_to_path
|
||||
|
||||
# Safety measure: refuse to download a file into the listing directory.
|
||||
if self._locator.is_system_path(download_to_path):
|
||||
raise ValueError(
|
||||
("Asset at {!s} wants to be downloaded to {!s}, which would overwrite local asset system files. " +
|
||||
"Notify the owner of the asset library about this.").format(
|
||||
remote_url,
|
||||
download_to_path))
|
||||
|
||||
logger.info("downloading %s to %s", remote_url, download_to_path)
|
||||
|
||||
assert self._bg_downloader, "downloads can only be queued when the bgdownloader is available"
|
||||
request_descr = self._bg_downloader.queue_download(
|
||||
remote_url,
|
||||
download_to_path,
|
||||
http_method=self._HTTP_METHOD,
|
||||
)
|
||||
return request_descr.url
|
||||
|
||||
# TODO: implement this in a more useful way:
|
||||
def report(self, level: set[str], message: str) -> None:
|
||||
# logger.info("Report: {:s}: {:s}".format("/".join(level), message))
|
||||
if 'ERROR' in level:
|
||||
self._error_message = message
|
||||
|
||||
def cancel_and_shutdown(self) -> None:
|
||||
"""Cancel all downloads and shut down the background downloader."""
|
||||
|
||||
# Only set to 'Cancelled' if the downloader was still downloading.
|
||||
if self._status == DownloadStatus.DOWNLOADING:
|
||||
if self._bg_downloader and self._bg_downloader.num_pending_downloads > 0:
|
||||
self._status = DownloadStatus.CANCELLED
|
||||
else:
|
||||
self._status = DownloadStatus.FINISHED
|
||||
|
||||
# The downloads themselves don't have to be explicitly cancelled,
|
||||
# shutting down the downloader will do that implicitly.
|
||||
self.shutdown()
|
||||
|
||||
# By now there is no more queue, so just treat it as 'empty' and let Blender know no downloads will happen any
|
||||
# more (at least not by this downloader).
|
||||
if self._on_queue_empty_callback is not None:
|
||||
# Call the callback _after_ setting the status, so that when
|
||||
# Blender is pinged about this, it can see it's finished.
|
||||
self._on_queue_empty_callback()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop the background downloader and call the 'done' callback."""
|
||||
|
||||
# The timer is no longer necessary, the bg_downloader.shutdown() call
|
||||
# takes care of the last queued messages.
|
||||
if bpy.app.timers.is_registered(self.on_timer_event):
|
||||
bpy.app.timers.unregister(self.on_timer_event)
|
||||
|
||||
try:
|
||||
if not self._bg_downloader:
|
||||
return
|
||||
|
||||
# Only report if this is actually triggering a shutdown. If that was
|
||||
# already triggered somehow, don't bother.
|
||||
if not self._bg_downloader.is_shutdown_requested:
|
||||
# It may be tempting to call self.report(...) here, and report on the
|
||||
# cancellation. However, this should be done by the caller, when they know
|
||||
# of the reason of the cancellation and thus can provide more info.
|
||||
num_pending = self._bg_downloader.num_pending_downloads
|
||||
if num_pending:
|
||||
logger.warning("Shutting down background downloader, %d downloads pending", num_pending)
|
||||
|
||||
self._bg_downloader.shutdown()
|
||||
finally:
|
||||
# Regardless of whether the shutdown had some issues, the timer has
|
||||
# been unregistered, so there will be no more message handling, and
|
||||
# so for all intents and purposes, the downloader is done.
|
||||
self._bg_downloader = None
|
||||
|
||||
def on_timer_event(self) -> float:
|
||||
assert self._bg_downloader, "timer events should only come in while the bgdownloader is available"
|
||||
|
||||
try:
|
||||
self._bg_downloader.update()
|
||||
except http_dl.BackgroundProcessNotRunningError:
|
||||
logger.error("Background downloader subprocess died, aborting.")
|
||||
self._status = DownloadStatus.FAILED
|
||||
self.shutdown()
|
||||
return 0 # Deactivate the timer.
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Unexpected error downloading remote asset library ilisting from %s to %s",
|
||||
self._locator.remote_url,
|
||||
self._locator.local_path)
|
||||
|
||||
# Automatically switch between IDLE and DOWNLOADING, but never overwrite
|
||||
# FAILED or FINISHED_SUCCESFULLY.
|
||||
if self._status in {DownloadStatus.DOWNLOADING, DownloadStatus.IDLE}:
|
||||
if self._bg_downloader.num_pending_downloads > 0:
|
||||
self._status = DownloadStatus.DOWNLOADING
|
||||
else:
|
||||
self._status = DownloadStatus.IDLE
|
||||
|
||||
return self._DOWNLOAD_POLL_INTERVAL
|
||||
|
||||
@property
|
||||
def remote_url(self) -> str:
|
||||
return self._locator.remote_url
|
||||
|
||||
@property
|
||||
def local_path(self) -> Path:
|
||||
return self._locator.local_path
|
||||
|
||||
@property
|
||||
def status(self) -> DownloadStatus:
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def error_message(self) -> str:
|
||||
return self._error_message
|
||||
|
||||
# Below here: http_dl.DownloadReporter protocol functions:
|
||||
|
||||
def download_starts(self, http_req_descr: http_dl.RequestDescription) -> None:
|
||||
pass
|
||||
|
||||
def already_downloaded(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
) -> None:
|
||||
self._shutdown_if_done()
|
||||
|
||||
def download_error(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
if isinstance(error, http_dl.DownloadCancelled):
|
||||
# Cancelling a download should cancel all queued-up downloads.
|
||||
if self._num_assets_pending:
|
||||
self.report({'WARNING'}, "Cancelled {} pending download".format(self._num_assets_pending))
|
||||
logger.warning("Download cancelled: %s", http_req_descr)
|
||||
self._status = DownloadStatus.FAILED
|
||||
self.shutdown()
|
||||
return
|
||||
|
||||
self._shutdown_if_done()
|
||||
|
||||
def download_progress(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
progress: http_dl.DownloadProgress,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def download_finished(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
) -> None:
|
||||
self._shutdown_if_done()
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AssetReporter:
|
||||
"""Implementation of the http_dl.DownloadReporter protocol."""
|
||||
|
||||
asset_library_url: str
|
||||
|
||||
def download_starts(self, http_req_descr: http_dl.RequestDescription) -> None:
|
||||
logger.debug("Download starting: %s", http_req_descr)
|
||||
|
||||
def already_downloaded(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
) -> None:
|
||||
logger.debug("Download unnecessary, file already downloaded: %s", http_req_descr.url)
|
||||
bpy.types.WindowManager.asset_library_status_ping_asset_file_succeeded(
|
||||
self.asset_library_url, http_req_descr.url, str(local_file))
|
||||
|
||||
def download_error(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
logger.warning("Could not download file %s: %s", http_req_descr, error)
|
||||
bpy.types.WindowManager.asset_library_status_ping_asset_file_failed(
|
||||
self.asset_library_url, http_req_descr.url, str(local_file))
|
||||
|
||||
def download_progress(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
progress: http_dl.DownloadProgress,
|
||||
) -> None:
|
||||
bpy.types.WindowManager.asset_library_status_ping_asset_file_progress(
|
||||
http_req_descr.url, progress.disk_bytes_written)
|
||||
|
||||
def download_finished(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
) -> None:
|
||||
logger.info("Download finished: %s to %s", http_req_descr, local_file)
|
||||
bpy.types.WindowManager.asset_library_status_ping_asset_file_succeeded(
|
||||
self.asset_library_url, http_req_descr.url, str(local_file))
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PreviewReporter:
|
||||
"""Implementation of the http_dl.DownloadReporter protocol."""
|
||||
|
||||
def download_starts(self, http_req_descr: http_dl.RequestDescription) -> None:
|
||||
logger.debug("Download starting: %s", http_req_descr)
|
||||
|
||||
def already_downloaded(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
) -> None:
|
||||
# This cannot check the content-type header (like download_finished() does), since
|
||||
# there likely is none in a '304 Not Modified' response.
|
||||
|
||||
# Indicate to a future run that we just confirmed this file is still fresh.
|
||||
local_file.touch()
|
||||
|
||||
# Poke Blender so it knows there's a thumbnail update. It shouldn't be necessary, but since it requested the
|
||||
# file for downloading, it may not have been aware it already existed. Better let it know.
|
||||
bpy.types.WindowManager.asset_library_status_ping_loaded_new_preview(str(local_file))
|
||||
|
||||
def download_error(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
# TODO: create an empty file in the correct `.../_thumbs/failed` directory.
|
||||
self.download_finished(http_req_descr, local_file)
|
||||
|
||||
def download_progress(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
progress: http_dl.DownloadProgress,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def download_finished(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
) -> None:
|
||||
# Check whether the file was actually an image.
|
||||
assert http_req_descr.response_headers
|
||||
content_type = http_req_descr.response_headers.get('content-type', "")
|
||||
|
||||
# Only check the content type if the server sends it back. Otherwise
|
||||
# just trust that it's valid. For example, when sending a `304 Not
|
||||
# Modified`, the server may actually skip the Content-Type header.
|
||||
if content_type and not content_type.startswith('image/'):
|
||||
logger.warning("Thumbnail URL %r has content type %r, expected an image",
|
||||
http_req_descr.url, content_type)
|
||||
# TODO: mark as 'failed' so that this file isn't repeatedly
|
||||
# downloaded and rejected. For now I'll just keep the file
|
||||
# around, so that at least the time-stamping works to prevent
|
||||
# hammering the server.
|
||||
|
||||
# Indicate to a future run that we just confirmed this file is still fresh.
|
||||
local_file.touch()
|
||||
|
||||
# Poke Blender so it knows there's a thumbnail update.
|
||||
bpy.types.WindowManager.asset_library_status_ping_loaded_new_preview(str(local_file))
|
||||
@@ -0,0 +1,294 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Generated by datamodel-codegen:
|
||||
# source filename: blender_asset_library_openapi.yaml
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Contact:
|
||||
"""Owner / publisher of this asset library."""
|
||||
|
||||
name: str
|
||||
url: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class URLWithHash:
|
||||
"""Resource that's identified by a URL.
|
||||
|
||||
The resource should be fetched by including the hash in the query
|
||||
string, like `GET {URL}?hash={HASH}`. Here `{HASH}` should _not_
|
||||
include the hash type. The purpose of including this on the URL is
|
||||
for cache busting, and thus the hash type is not relevant here.
|
||||
"""
|
||||
|
||||
url: str
|
||||
"""URL of the page file."""
|
||||
|
||||
hash: str
|
||||
"""Hash of the resource obtained at that URL.
|
||||
|
||||
This should be in the format "HASHTYPE:HASH-AS-HEX". Currently only
|
||||
the "SHA256" hash type is supported. Note that for dynamic API
|
||||
servers, which may perform a server-side filter on the data, the
|
||||
actual response may not have the same hash. Static servers send
|
||||
content that matches the hash.
|
||||
"""
|
||||
|
||||
|
||||
type AssetIDTypeV1 = str
|
||||
"""Type of the Blender data-block.
|
||||
|
||||
This can be obtained via BPY with `datablock.id_type`. Any comparisons
|
||||
should be done in a case-insensitive manner. Note that this list is just
|
||||
a list of data-block types in Blender. This type being in this list does
|
||||
not mean that Blender supports making this data-block an asset. It's
|
||||
just here to ensure that if that changes, and more data-block types can
|
||||
become assets, this schema doesn't need updating.
|
||||
"""
|
||||
|
||||
|
||||
class CustomPropertyTypeV1(StrEnum):
|
||||
"""Type of IDProperty, see `eIDPropertyType` in `DNA_ID_enumms.h`.
|
||||
|
||||
For now, type `ID` and `IDPARRAY` are not supported.
|
||||
"""
|
||||
|
||||
IDP_STRING = "IDP_STRING"
|
||||
IDP_INT = "IDP_INT"
|
||||
IDP_FLOAT = "IDP_FLOAT"
|
||||
IDP_ARRAY = "IDP_ARRAY"
|
||||
IDP_GROUP = "IDP_GROUP"
|
||||
IDP_DOUBLE = "IDP_DOUBLE"
|
||||
IDP_BOOL = "IDP_BOOL"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetBlenderVersionsV1:
|
||||
"""Minimum and (optionally) maximum versions of Blender that this asset
|
||||
should be shown in.
|
||||
|
||||
This is a half-open interval: Blender shows the asset if `min <= blender < until`.
|
||||
"""
|
||||
|
||||
min: str
|
||||
"""Minimum version of Blender that should show this asset."""
|
||||
|
||||
until: str | None = None
|
||||
"""First version of Blender that should NOT show this asset."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogV1:
|
||||
"""An asset catalog, which can be represented by one or more UUIDs."""
|
||||
|
||||
path: str
|
||||
uuids: list[str]
|
||||
simple_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileV1:
|
||||
"""Single file in the asset library.
|
||||
|
||||
Identified by its relative path in that library.
|
||||
"""
|
||||
|
||||
path: str
|
||||
"""Relative path of where this file is located in the asset library."""
|
||||
|
||||
size_in_bytes: int
|
||||
hash: str
|
||||
"""Hash of the file.
|
||||
|
||||
This should be in the format "HASHTYPE:HASH-AS-HEX". Currently only
|
||||
the "SHA256" hash type is supported.
|
||||
"""
|
||||
|
||||
blender_version: str
|
||||
"""Version of Blender used to write this file.
|
||||
|
||||
Only contains the major and minor version, no patch version ("5.2",
|
||||
"6.3", etc. but not "5.2.1").
|
||||
"""
|
||||
|
||||
url: str | None = None
|
||||
"""URL where the file can be downloaded.
|
||||
|
||||
If the URL is relative, it is to be interpreted as relative to the
|
||||
library's root URL. If the URL is not given, or an empty string, it
|
||||
is assumed to be the same as 'path'.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetLibraryMeta:
|
||||
"""Meta-data of this asset library."""
|
||||
|
||||
api_versions: dict[str, URLWithHash]
|
||||
"""API versions of this asset library.
|
||||
|
||||
This is reflected in the URLs of all OpenAPI operations except the
|
||||
one to get this metadata. A single asset library can expose multiple
|
||||
versions, in order to be backward-compatible with older versions of
|
||||
Blender. Keys should be "v1", "v2", etc. and their values should be
|
||||
a URLWithHash that points to each version's index file.
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""Name of this asset library."""
|
||||
|
||||
contact: Contact
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetLibraryIndexV1:
|
||||
"""The available assets at this library."""
|
||||
|
||||
schema_version: str
|
||||
"""Version number of the used schema.
|
||||
|
||||
This should be the same as the version of this OpenAPI definition,
|
||||
as described in its 'info.version' field.
|
||||
"""
|
||||
|
||||
asset_size_bytes: int
|
||||
asset_count: int
|
||||
"""Total number of assets in this index.
|
||||
|
||||
This is the sum of all `asset_count` fields of each page.
|
||||
"""
|
||||
|
||||
file_count: int
|
||||
"""Total number of files in this index.
|
||||
|
||||
This is the sum of all `file_count` fields of each page (after
|
||||
deduplication).
|
||||
"""
|
||||
|
||||
pages: list[URLWithHash]
|
||||
"""URLs of the individual asset index pages.
|
||||
|
||||
When relative, these are taken as relative to the main server URL
|
||||
(i.e. the root of all paths defined in this OpenAPI spec).
|
||||
"""
|
||||
|
||||
catalogs: list[CatalogV1] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetLibraryIndexPageV1:
|
||||
"""Any number of assets."""
|
||||
|
||||
asset_count: int
|
||||
"""Number of assets in this page.
|
||||
|
||||
This is declared separately, so that a partial JSON parser has this
|
||||
information before the entire file is downloaded and parsed.
|
||||
"""
|
||||
|
||||
file_count: int
|
||||
"""Number of files in this page.
|
||||
|
||||
This is declared separately, so that a partial JSON parser has this
|
||||
information before the entire file is downloaded and parsed.
|
||||
"""
|
||||
|
||||
assets: list[AssetV1]
|
||||
files: list[FileV1]
|
||||
"""The files that are referenced by the above assets.
|
||||
|
||||
Note that there may be duplication of this information between asset
|
||||
pages, as each file can contain multiple assets, and those assets
|
||||
might be scattered across multiple pages.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetV1:
|
||||
"""Representation of a single asset.
|
||||
|
||||
Assets are always Blender data-blocks in some blend file. This asset
|
||||
may be stored in the same blend file as other assets, and so it does
|
||||
_not_ represent a single downloadable item.
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""Name of the Blender data-block."""
|
||||
|
||||
id_type: AssetIDTypeV1
|
||||
files: list[str]
|
||||
"""Relative paths of the files that contain this asset.
|
||||
|
||||
The first entry in the list MUST contain the asset data-block
|
||||
itself, while the remaining entries can be in any order. These
|
||||
relative paths are used to look up more file information in the
|
||||
asset library's list of files.
|
||||
"""
|
||||
|
||||
bl_versions: AssetBlenderVersionsV1
|
||||
thumbnail: URLWithHash | None = None
|
||||
meta: AssetMetadataV1 | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetMetadataV1:
|
||||
"""Metadata of an asset, as defined by Blender's `AssetMeta` DNA struct.
|
||||
|
||||
Fields should either be non-empty or absent.
|
||||
"""
|
||||
|
||||
catalog_id: str | None = None
|
||||
"""The catalog UUID that contains this asset.
|
||||
|
||||
Having the UUID here makes it easier to create a per-blendfile
|
||||
.cats.txt file, if that's ever necessary.
|
||||
"""
|
||||
|
||||
preferred_import_method: str | None = None
|
||||
"""The import method preferred by this asset.
|
||||
|
||||
For example, base meshes for sculpting can declare they should
|
||||
always be appended, making them instantly usable for sculpting.
|
||||
Supports values APPEND, APPEND_REUSE, and ASSET_IMPORT_PACK. These
|
||||
are not modeled here as an enum, to aid in forward compatibility of
|
||||
this Blender version with future import methods (it'll just ignore
|
||||
unsupported methods, instead of rejecting the file as invalid).
|
||||
"""
|
||||
|
||||
tags: list[str] | None = None
|
||||
author: str | None = None
|
||||
description: str | None = None
|
||||
license: str | None = None
|
||||
copyright: str | None = None
|
||||
properties: CustomPropertiesV1 | None = None
|
||||
|
||||
|
||||
type CustomPropertiesV1 = list[CustomPropertyV1]
|
||||
"""Arbitrary custom properties of the asset.
|
||||
|
||||
Keys are the property names.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomPropertyV1:
|
||||
"""Single 'custom property' value of the asset.
|
||||
|
||||
The value should be compatible with the given type; GROUP properties
|
||||
should be represented as `CustomPropertiesV1` object again. Arrays
|
||||
should specify an `itemtype`.
|
||||
"""
|
||||
name: str
|
||||
type: CustomPropertyTypeV1
|
||||
value: CustomPropertiesV1 | list[Any] | float | int | str | bool
|
||||
itemtype: CustomPropertyTypeV1 | None = None
|
||||
@@ -0,0 +1,411 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# This is the OpenAPI specification for Blender's Remote Assets system.
|
||||
#
|
||||
# At this moment, the `paths` section is not used by the Blender code, and is
|
||||
# here just for referencing by humans. It is also still being designed, so don't
|
||||
# take it as set in stone.
|
||||
#
|
||||
# The Python code generator just uses the data structures specified by the
|
||||
# `components` section.
|
||||
#
|
||||
# --------------------------------------------------------------------------
|
||||
# Run `ninja generate_datamodels` from the build directory to regenerate the
|
||||
# Python code in blender_asset_library_openapi.py. Replace `ninja` with your
|
||||
# build tool of choice.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
version: 1.0.0
|
||||
title: Blender Asset Library API
|
||||
description: Blender's API for describing and fetching assets from online libraries.
|
||||
contact:
|
||||
name: Blender
|
||||
url: https://www.blender.org/
|
||||
license:
|
||||
name: GPLv3
|
||||
url: https://www.gnu.org/licenses/gpl-3.0.en.html
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
## Meta
|
||||
|
||||
/_asset-library-meta.json:
|
||||
summary: Meta-information about this asset library.
|
||||
get:
|
||||
summary: Retrieve the asset library meta info.
|
||||
operationId: getLibraryMeta
|
||||
tags: [meta]
|
||||
responses:
|
||||
"200":
|
||||
description: normal response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssetLibraryMeta"
|
||||
|
||||
## Index
|
||||
/_v1/asset-index.json:
|
||||
summary: The index of the asset library, containing the metadata of all available assets.
|
||||
get:
|
||||
summary: Get the asset library index.
|
||||
operationId: getLibraryIndex
|
||||
tags: [index]
|
||||
responses:
|
||||
"200":
|
||||
description: normal response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssetLibraryIndexV1"
|
||||
|
||||
/_v1/assets-{page}.json:
|
||||
summary: >
|
||||
The index of the asset library, containing the metadata of all available assets.
|
||||
Note that the actual URLs of these pages are listed in the `asset-index.json` above.
|
||||
The path specified here is merely a suggestion.
|
||||
get:
|
||||
summary: Get the asset library index.
|
||||
operationId: getLibraryIndexPage
|
||||
tags: [index]
|
||||
parameters:
|
||||
- name: page
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"200":
|
||||
description: normal response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssetLibraryIndexPageV1"
|
||||
|
||||
tags:
|
||||
- name: meta
|
||||
description: Info about the asset library itself.
|
||||
- name: index
|
||||
description: Access to the asset library's list of assets.
|
||||
|
||||
components:
|
||||
schemas:
|
||||
## Meta
|
||||
|
||||
AssetLibraryMeta:
|
||||
type: object
|
||||
description: "Meta-data of this asset library."
|
||||
properties:
|
||||
"api_versions":
|
||||
type: object
|
||||
description: >
|
||||
API versions of this asset library. This is reflected in the URLs of
|
||||
all OpenAPI operations except the one to get this metadata.
|
||||
|
||||
A single asset library can expose multiple versions, in order to be
|
||||
backward-compatible with older versions of Blender.
|
||||
|
||||
Keys should be "v1", "v2", etc. and their values should be a
|
||||
URLWithHash that points to each version's index file.
|
||||
additionalProperties: { $ref: "#/components/schemas/URLWithHash" }
|
||||
"name":
|
||||
type: string
|
||||
description: Name of this asset library.
|
||||
"contact": { $ref: "#/components/schemas/Contact" }
|
||||
required: [api_versions, name, contact]
|
||||
example:
|
||||
api_versions:
|
||||
v1:
|
||||
url: _v1/asset-index.json
|
||||
hash: "SHA256:22c9d2d5e9fe119b43fb8437df06c88e61d3bbad315690284b9eece66641c1e9"
|
||||
name: Blender Essentials
|
||||
contact:
|
||||
name: Blender
|
||||
url: https://www.blender.org/
|
||||
|
||||
Contact:
|
||||
type: object
|
||||
description: Owner / publisher of this asset library.
|
||||
properties:
|
||||
"name": { type: string }
|
||||
"url": { type: string }
|
||||
"email": { type: string }
|
||||
required: [name]
|
||||
|
||||
## Index
|
||||
|
||||
AssetLibraryIndexV1:
|
||||
type: object
|
||||
description: The available assets at this library.
|
||||
properties:
|
||||
"schema_version":
|
||||
type: string
|
||||
description: >
|
||||
Version number of the used schema. This should be the same as the
|
||||
version of this OpenAPI definition, as described in its
|
||||
'info.version' field.
|
||||
"asset_size_bytes": { type: integer }
|
||||
"asset_count":
|
||||
type: integer
|
||||
description: >
|
||||
Total number of assets in this index. This is the sum of all
|
||||
`asset_count` fields of each page.
|
||||
"file_count":
|
||||
type: integer
|
||||
description: >
|
||||
Total number of files in this index. This is the sum of all
|
||||
`file_count` fields of each page (after deduplication).
|
||||
"pages":
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/URLWithHash" }
|
||||
description: >
|
||||
URLs of the individual asset index pages. When relative, these are
|
||||
taken as relative to the main server URL (i.e. the root of all paths
|
||||
defined in this OpenAPI spec).
|
||||
"catalogs":
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/CatalogV1" }
|
||||
required:
|
||||
[schema_version, asset_size_bytes, asset_count, file_count, pages]
|
||||
|
||||
URLWithHash:
|
||||
type: object
|
||||
description: >
|
||||
Resource that's identified by a URL. The resource should be fetched by
|
||||
including the hash in the query string, like `GET {URL}?hash={HASH}`.
|
||||
Here `{HASH}` should _not_ include the hash type. The purpose of
|
||||
including this on the URL is for cache busting, and thus the hash type
|
||||
is not relevant here.
|
||||
properties:
|
||||
"url":
|
||||
type: string
|
||||
description: URL of the page file
|
||||
"hash":
|
||||
type: string
|
||||
description: >
|
||||
Hash of the resource obtained at that URL. This should be in the
|
||||
format "HASHTYPE:HASH-AS-HEX". Currently only the "SHA256" hash type
|
||||
is supported.
|
||||
|
||||
Note that for dynamic API servers, which may perform a server-side
|
||||
filter on the data, the actual response may not have the same hash.
|
||||
Static servers send content that matches the hash.
|
||||
required: [url, hash]
|
||||
|
||||
AssetLibraryIndexPageV1:
|
||||
type: object
|
||||
description: Any number of assets.
|
||||
properties:
|
||||
"asset_count":
|
||||
type: integer
|
||||
description: >
|
||||
Number of assets in this page. This is declared separately, so that
|
||||
a partial JSON parser has this information before the entire file is
|
||||
downloaded and parsed.
|
||||
"file_count":
|
||||
type: integer
|
||||
description: >
|
||||
Number of files in this page. This is declared separately, so that
|
||||
a partial JSON parser has this information before the entire file is
|
||||
downloaded and parsed.
|
||||
"assets":
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/AssetV1" }
|
||||
"files":
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/FileV1" }
|
||||
description: >
|
||||
The files that are referenced by the above assets. Note that there
|
||||
may be duplication of this information between asset pages, as each
|
||||
file can contain multiple assets, and those assets might be
|
||||
scattered across multiple pages.
|
||||
required: [asset_count, file_count, assets, files]
|
||||
|
||||
AssetV1:
|
||||
type: object
|
||||
description: >
|
||||
Representation of a single asset. Assets are always Blender data-blocks
|
||||
in some blend file.
|
||||
|
||||
This asset may be stored in the same blend file as other assets, and so
|
||||
it does _not_ represent a single downloadable item.
|
||||
properties:
|
||||
"name":
|
||||
type: string
|
||||
description: Name of the Blender data-block.
|
||||
"id_type": { $ref: "#/components/schemas/AssetIDTypeV1" }
|
||||
"files":
|
||||
type: array
|
||||
items: { type: string }
|
||||
minItems: 1
|
||||
description: >
|
||||
Relative paths of the files that contain this asset. The first entry
|
||||
in the list MUST contain the asset data-block itself, while the
|
||||
remaining entries can be in any order. These relative paths are used
|
||||
to look up more file information in the asset library's list of
|
||||
files.
|
||||
"thumbnail": { $ref: "#/components/schemas/URLWithHash" }
|
||||
"meta": { $ref: "#/components/schemas/AssetMetadataV1" }
|
||||
"bl_versions":
|
||||
$ref: "#/components/schemas/AssetBlenderVersionsV1"
|
||||
required:
|
||||
- "name"
|
||||
- "id_type"
|
||||
- "files"
|
||||
- "bl_versions"
|
||||
|
||||
AssetIDTypeV1:
|
||||
type: string
|
||||
description: >
|
||||
Type of the Blender data-block.
|
||||
|
||||
This can be obtained via BPY with `datablock.id_type`. Any comparisons
|
||||
should be done in a case-insensitive manner.
|
||||
|
||||
Note that this list is just a list of data-block types in Blender. This
|
||||
type being in this list does not mean that Blender supports making this
|
||||
data-block an asset. It's just here to ensure that if that changes, and
|
||||
more data-block types can become assets, this schema doesn't need
|
||||
updating.
|
||||
|
||||
AssetMetadataV1:
|
||||
type: object
|
||||
description: >
|
||||
Metadata of an asset, as defined by Blender's `AssetMeta` DNA struct.
|
||||
Fields should either be non-empty or absent.
|
||||
properties:
|
||||
"catalog_id":
|
||||
type: string
|
||||
description: >
|
||||
The catalog UUID that contains this asset. Having the UUID here
|
||||
makes it easier to create a per-blendfile .cats.txt file, if that's
|
||||
ever necessary.
|
||||
"preferred_import_method":
|
||||
type: string
|
||||
description: >
|
||||
The import method preferred by this asset. For example, base meshes for
|
||||
sculpting can declare they should always be appended, making them
|
||||
instantly usable for sculpting.
|
||||
|
||||
Supports values APPEND, APPEND_REUSE, and ASSET_IMPORT_PACK.
|
||||
|
||||
These are not modeled here as an enum, to aid in forward compatibility
|
||||
of this Blender version with future import methods (it'll just ignore
|
||||
unsupported methods, instead of rejecting the file as invalid).
|
||||
"tags":
|
||||
type: array
|
||||
items: { type: string }
|
||||
minItems: 1
|
||||
"author": { type: string }
|
||||
"description": { type: string }
|
||||
"license": { type: string }
|
||||
"copyright": { type: string }
|
||||
"properties": { $ref: "#/components/schemas/CustomPropertiesV1" }
|
||||
|
||||
CustomPropertiesV1:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/CustomPropertyV1"
|
||||
description: >
|
||||
Arbitrary custom properties of the asset. Keys are the property names.
|
||||
|
||||
CustomPropertyV1:
|
||||
type: object
|
||||
description: >
|
||||
Single 'custom property' value of the asset. The value should be
|
||||
compatible with the given type; GROUP properties should be represented
|
||||
as `CustomPropertiesV1` object again. Arrays should specify an
|
||||
`itemtype`.
|
||||
properties:
|
||||
"name": { type: string }
|
||||
"type": { $ref: "#/components/schemas/CustomPropertyTypeV1" }
|
||||
"itemtype": { $ref: "#/components/schemas/CustomPropertyTypeV1" }
|
||||
"value":
|
||||
oneOf:
|
||||
- { $ref: "#/components/schemas/CustomPropertiesV1" }
|
||||
- { type: array }
|
||||
- { type: number }
|
||||
- { type: integer }
|
||||
- { type: string }
|
||||
- { type: boolean }
|
||||
required: [name, type, value]
|
||||
|
||||
CustomPropertyTypeV1:
|
||||
type: string
|
||||
description: >
|
||||
Type of IDProperty, see `eIDPropertyType` in `DNA_ID_enumms.h`. For now,
|
||||
type `ID` and `IDPARRAY` are not supported.
|
||||
enum:
|
||||
[
|
||||
IDP_STRING,
|
||||
IDP_INT,
|
||||
IDP_FLOAT,
|
||||
IDP_ARRAY,
|
||||
IDP_GROUP,
|
||||
IDP_DOUBLE,
|
||||
IDP_BOOL,
|
||||
]
|
||||
|
||||
AssetBlenderVersionsV1:
|
||||
type: object
|
||||
description: >
|
||||
Minimum and (optionally) maximum versions of Blender that this asset should be shown in.
|
||||
This is a half-open interval: Blender shows the asset if `min <= blender < until`.
|
||||
properties:
|
||||
"min":
|
||||
type: string
|
||||
description: Minimum version of Blender that should show this asset.
|
||||
"until":
|
||||
type: string
|
||||
description: First version of Blender that should NOT show this asset.
|
||||
required:
|
||||
- "min"
|
||||
|
||||
CatalogV1:
|
||||
type: object
|
||||
description: An asset catalog, which can be represented by one or more UUIDs.
|
||||
properties:
|
||||
"path": { type: string }
|
||||
"simple_name": { type: string }
|
||||
"uuids":
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minItems: 1
|
||||
required: [path, uuids]
|
||||
|
||||
FileV1:
|
||||
type: object
|
||||
description: >
|
||||
Single file in the asset library. Identified by its relative path in that library.
|
||||
properties:
|
||||
"path":
|
||||
type: string
|
||||
description: >
|
||||
Relative path of where this file is located in the asset library.
|
||||
"url":
|
||||
type: string
|
||||
description: >
|
||||
URL where the file can be downloaded. If the URL is relative, it is
|
||||
to be interpreted as relative to the library's root URL.
|
||||
|
||||
If the URL is not given, or an empty string, it is assumed to be the
|
||||
same as 'path'.
|
||||
"size_in_bytes": { type: integer }
|
||||
"hash":
|
||||
type: string
|
||||
description: >
|
||||
Hash of the file. This should be in the format "HASHTYPE:HASH-AS-HEX".
|
||||
Currently only the "SHA256" hash type is supported.
|
||||
"blender_version":
|
||||
type: string
|
||||
description: >
|
||||
Version of Blender used to write this file. Only contains the major and
|
||||
minor version, no patch version ("5.2", "6.3", etc. but not "5.2.1").
|
||||
required:
|
||||
- "path"
|
||||
- "size_in_bytes"
|
||||
- "hash"
|
||||
- "blender_version"
|
||||
@@ -0,0 +1,75 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
|
||||
|
||||
def main(cli_args: list[str]) -> None:
|
||||
"""CLI entry point for the 'asset_listing' CLI commands."""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="blender -c asset_listing",
|
||||
description="Manage asset library index files.",
|
||||
)
|
||||
|
||||
# func is set by subparsers to indicate which function to run.
|
||||
parser.set_defaults(func=None, loglevel=logging.INFO)
|
||||
|
||||
loggroup = parser.add_mutually_exclusive_group()
|
||||
loggroup.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
dest="loglevel",
|
||||
action="store_const",
|
||||
const=logging.DEBUG,
|
||||
help="Log DEBUG level and higher",
|
||||
)
|
||||
loggroup.add_argument(
|
||||
"-q",
|
||||
"--quiet",
|
||||
dest="loglevel",
|
||||
action="store_const",
|
||||
const=logging.WARNING,
|
||||
help="Log at WARNING level and higher",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(
|
||||
help="Choose a subcommand to actually make Blender do something. "
|
||||
"Global options go before the subcommand, "
|
||||
"whereas subcommand-specific options go after it. "
|
||||
"Use --help after the subcommand to get more info."
|
||||
)
|
||||
|
||||
from . import cli_listing_generator, cli_listing_downloader
|
||||
|
||||
cli_listing_generator.add_cli_parser(subparsers)
|
||||
cli_listing_downloader.add_cli_parser(subparsers)
|
||||
|
||||
args = parser.parse_args(cli_args)
|
||||
|
||||
config_logging(args)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
if not args.func:
|
||||
parser.error("No subcommand was given")
|
||||
|
||||
start_time = time.monotonic()
|
||||
args.func(args)
|
||||
|
||||
duration = datetime.timedelta(seconds=time.monotonic() - start_time)
|
||||
log.info("Command took %s to complete", duration)
|
||||
|
||||
|
||||
def config_logging(args) -> None: # type: ignore
|
||||
"""Configures the logging system based on CLI arguments."""
|
||||
|
||||
logging.basicConfig(
|
||||
level=args.loglevel,
|
||||
format="%(asctime)-15s %(levelname)8s %(threadName)10s %(name)16s %(message)s",
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import logging
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from . import listing_downloader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CLIArguments:
|
||||
"""Parsed command-line arguments."""
|
||||
|
||||
url: str
|
||||
|
||||
|
||||
def cli_main(arguments_raw: argparse.Namespace) -> None:
|
||||
"""Generate the index for the passed-on-the-CLI asset library path."""
|
||||
|
||||
# Parse CLI arguments.
|
||||
arguments = _parse_cli_args(arguments_raw)
|
||||
|
||||
base_path = Path(".").resolve() / "_asset_download_location" # TODO: be sensible.
|
||||
|
||||
is_done = False
|
||||
|
||||
def on_done_callback(_: listing_downloader.RemoteAssetListingDownloader) -> None:
|
||||
nonlocal is_done
|
||||
|
||||
is_done = True
|
||||
|
||||
downloader = listing_downloader.RemoteAssetListingDownloader(
|
||||
arguments.url,
|
||||
base_path,
|
||||
lambda *args: None,
|
||||
on_done_callback)
|
||||
downloader.download_and_process()
|
||||
|
||||
while not is_done:
|
||||
# Ordinarily Blender's timer system will call the right method. But
|
||||
# because this is intended to run headless, and we're blocking the main
|
||||
# thread here, that doesn't happen.
|
||||
downloader.on_timer_event()
|
||||
time.sleep(downloader._DOWNLOAD_POLL_INTERVAL)
|
||||
|
||||
print("Done!")
|
||||
|
||||
|
||||
# Ignore the type of the `subparsers` argument, because there doesn't seem
|
||||
# to be a way to make both static mypy and the runtime Python happy at the
|
||||
# same time.
|
||||
|
||||
|
||||
def add_cli_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
||||
"""Add argparser for this subcommand."""
|
||||
|
||||
parser = subparsers.add_parser("download", help="Download and parse a remote asset library index")
|
||||
parser.set_defaults(func=cli_main)
|
||||
|
||||
parser.add_argument(
|
||||
"url",
|
||||
type=str,
|
||||
help="""URL of the remote asset library""",
|
||||
)
|
||||
|
||||
|
||||
def _parse_cli_args(arguments_raw: argparse.Namespace) -> CLIArguments:
|
||||
"""Make sure the passed arguments are valid."""
|
||||
|
||||
try:
|
||||
urllib.parse.urlparse(arguments_raw.url)
|
||||
except ValueError as ex:
|
||||
logger.error("invalid URL specified: {}".format(ex))
|
||||
|
||||
arguments = CLIArguments(
|
||||
url=arguments_raw.url,
|
||||
)
|
||||
|
||||
return arguments
|
||||
|
||||
|
||||
class APIVersionError(Exception):
|
||||
"""Raised when none of the API versions declared by a remote asset library are supported by Blender."""
|
||||
@@ -0,0 +1,283 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""Blender Online Asset Repository Listing Generator."""
|
||||
|
||||
__all__ = (
|
||||
'cli_main',
|
||||
'SCHEMA_VERSION',
|
||||
)
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cattrs.preconf.json
|
||||
|
||||
from . import hashing, listing_asset_catalogs, listing_common, json_parsing
|
||||
from . import cli_listing_generator_asset_finder as asset_finder
|
||||
from . import cli_listing_generator_pagination as pagination
|
||||
from . import blender_asset_library_openapi as api_models
|
||||
|
||||
SCHEMA_VERSION = "1.0.0"
|
||||
DEFAULT_METADATA = api_models.AssetLibraryMeta(
|
||||
api_versions={}, # Determined by cli_main().
|
||||
name="Your Asset Library",
|
||||
contact=api_models.Contact(
|
||||
name="Your Name",
|
||||
url="https://example.org/",
|
||||
email="example@example.org",
|
||||
),
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_converter = cattrs.preconf.json.JsonConverter(omit_if_default=True)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CLIArguments:
|
||||
"""Parsed commandline arguments."""
|
||||
|
||||
repository: Path
|
||||
limit: int
|
||||
page_size: int
|
||||
|
||||
|
||||
def cli_main(arguments_raw: argparse.Namespace) -> None:
|
||||
"""Generate the index for the passed-on-the-CLI asset library path."""
|
||||
|
||||
# Parse CLI arguments.
|
||||
arguments = _parse_cli_args(arguments_raw)
|
||||
|
||||
# Read the top-level meta file first. If this already exists, an attempt
|
||||
# at parsing & upgrading it is performed. Better to do this (and stop on
|
||||
# errors) before diving into the assets themselves.
|
||||
meta_json_path = arguments.repository / listing_common.ASSET_TOP_METADATA_FILENAME
|
||||
toplevel_meta = _toplevel_meta_read(meta_json_path)
|
||||
|
||||
# Find all .blend files.
|
||||
filepaths: list[Path] = []
|
||||
logger.info("Traversing %s", arguments.repository)
|
||||
for filepath in arguments.repository.rglob("*.blend"):
|
||||
filepaths.append(filepath)
|
||||
|
||||
files_total = len(filepaths)
|
||||
logger.info(f"* {files_total} .blend files found.")
|
||||
|
||||
limit = _total_files_to_process(arguments, files_total)
|
||||
|
||||
# Find the assets in the blend files.
|
||||
logger.info("Parsing the files...")
|
||||
assets: list[api_models.AssetV1] = []
|
||||
files: list[api_models.FileV1] = []
|
||||
|
||||
for i, filepath in enumerate(filepaths[:limit]):
|
||||
logger.info(f"* {i + 1}/{limit}: {filepath.relative_to(arguments.repository)}")
|
||||
|
||||
bfile_info, assets_in_file = asset_finder.list_assets(filepath, arguments.repository)
|
||||
if not assets_in_file:
|
||||
continue
|
||||
|
||||
assets.extend(assets_in_file)
|
||||
files.append(bfile_info)
|
||||
|
||||
_sort_assets(assets)
|
||||
|
||||
# Write the listing index and the pages:
|
||||
asset_index_pages = pagination.paginate_asset_list(assets, files, arguments.page_size)
|
||||
index_path = _write_json_files(arguments, asset_index_pages)
|
||||
|
||||
# Write the top-level meta file:
|
||||
api_version_key = "v{:d}".format(listing_common.API_VERSION)
|
||||
index_relpath: Path = index_path.relative_to(arguments.repository)
|
||||
toplevel_meta.api_versions[api_version_key] = api_models.URLWithHash(
|
||||
url=urllib.parse.quote(index_relpath.as_posix()),
|
||||
hash=hashing.hash_file(index_path),
|
||||
)
|
||||
_save_json(toplevel_meta, meta_json_path)
|
||||
|
||||
|
||||
def _toplevel_meta_read(meta_json_path: Path) -> api_models.AssetLibraryMeta:
|
||||
try:
|
||||
metadata = _toplevel_metadata(meta_json_path)
|
||||
except (json.JSONDecodeError, cattrs.errors.ClassValidationError) as ex:
|
||||
msg = "Metadata file {} could not be parsed: {}"
|
||||
logger.error(msg.format(meta_json_path, ex))
|
||||
raise SystemExit(1) from None
|
||||
return metadata
|
||||
|
||||
|
||||
def _sort_assets(assets: list[api_models.AssetV1]) -> None:
|
||||
"""Sorts the assets in-place.
|
||||
|
||||
Sorting helps to get the generated listing stable, so that a diff between
|
||||
two runs of the generator is as clean as possible.
|
||||
"""
|
||||
|
||||
# Sort assets by their primary filename first. This places related assets together, and minimizes the repeats of the
|
||||
# same file across multiple listing pages.
|
||||
def sort_key(asset: api_models.AssetV1) -> tuple[str, str, str]:
|
||||
if asset.files:
|
||||
first_file = asset.files[0].lower()
|
||||
else:
|
||||
first_file = ""
|
||||
return (first_file, asset.id_type.lower(), asset.name.lower())
|
||||
|
||||
assets.sort(key=sort_key)
|
||||
|
||||
|
||||
def _write_json_files(
|
||||
arguments: CLIArguments,
|
||||
asset_index_pages: list[api_models.AssetLibraryIndexPageV1],
|
||||
) -> Path:
|
||||
"""Write the asset listing page files and the index file.
|
||||
|
||||
:returns: the path of the index file.
|
||||
"""
|
||||
outdir_root = arguments.repository
|
||||
outdir_versioned = outdir_root / listing_common.API_VERSIONED_SUBDIR
|
||||
|
||||
# Remove old pages, in case the number of assets per page was increased and
|
||||
# so less page files are needed.
|
||||
existing_pages = outdir_versioned.glob("assets-*.json")
|
||||
for filepath in existing_pages:
|
||||
filepath.unlink()
|
||||
|
||||
# Library Index Page /_v1/assets-{page}.json
|
||||
#
|
||||
# Note that these paths are determined by the generator, and their URLs are
|
||||
# listed explicitly in the index file, so there is no need to have those in
|
||||
# the listing_common.py file.
|
||||
page_infos: list[api_models.URLWithHash] = []
|
||||
for page_index, page in enumerate(asset_index_pages):
|
||||
page_relpath = listing_common.api_versioned(f"assets-{page_index:05}.json")
|
||||
page_abspath = outdir_root / page_relpath
|
||||
_save_json(page, page_abspath)
|
||||
|
||||
page_infos.append(api_models.URLWithHash(
|
||||
url=urllib.parse.quote(page_relpath.as_posix()),
|
||||
hash=hashing.hash_file(page_abspath),
|
||||
))
|
||||
|
||||
# Library Index file /_v1/asset-index.json:
|
||||
total_asset_count = sum(page.asset_count for page in asset_index_pages)
|
||||
total_file_count = sum(page.file_count for page in asset_index_pages)
|
||||
asset_size_bytes = sum(file.size_in_bytes
|
||||
for page in asset_index_pages
|
||||
for file in page.files)
|
||||
asset_cats = listing_asset_catalogs.parse_catalogs(arguments.repository)
|
||||
index = api_models.AssetLibraryIndexV1(
|
||||
schema_version=SCHEMA_VERSION,
|
||||
asset_size_bytes=asset_size_bytes,
|
||||
asset_count=total_asset_count,
|
||||
file_count=total_file_count,
|
||||
pages=page_infos,
|
||||
catalogs=asset_cats,
|
||||
)
|
||||
index_path = outdir_versioned / listing_common.ASSET_INDEX_JSON_FILENAME
|
||||
_save_json(index, index_path)
|
||||
|
||||
return index_path
|
||||
|
||||
|
||||
def _save_json(model: Any, json_path: Path) -> None:
|
||||
as_json = _converter.dumps(model, indent=2)
|
||||
|
||||
json_path.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
logger.info("Writing %s", json_path)
|
||||
with json_path.open("wt") as json_file:
|
||||
json_file.write(as_json)
|
||||
|
||||
|
||||
def _toplevel_metadata(json_path: Path) -> api_models.AssetLibraryMeta:
|
||||
"""Construct the top-level metadata.
|
||||
|
||||
Returns the metadata, or raises an exception (see json_parsing.ValidatingParser)
|
||||
if it is not valid JSON.
|
||||
|
||||
Writing is considered safe, except when the file exists but does not contain
|
||||
valid JSON. In that case, it's better to warn about this and keep the file
|
||||
as-is, so that the user can either delete or fix it.
|
||||
"""
|
||||
try:
|
||||
json_data = json_path.read_bytes()
|
||||
except IOError:
|
||||
# Ignore any read errors, as this likely means the file simply doesn't exist.
|
||||
return DEFAULT_METADATA
|
||||
|
||||
parser = json_parsing.ValidatingParser()
|
||||
metadata = parser.parse_and_validate(api_models.AssetLibraryMeta, json_data)
|
||||
|
||||
# Update the metadata to declare the API version for which we're going to
|
||||
# write the data.
|
||||
metadata.api_versions = DEFAULT_METADATA.api_versions.copy()
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
# Ignore the type of the `subparsers` argument, because there doesn't seem
|
||||
# to be a way to make both static mypy and the runtime Python happy at the
|
||||
# same time.
|
||||
def add_cli_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
||||
"""Add argparser for this subcommand."""
|
||||
|
||||
parser = subparsers.add_parser("generate", help="Generate files necessary to serve an asset library")
|
||||
parser.set_defaults(func=cli_main)
|
||||
|
||||
parser.add_argument(
|
||||
"repository",
|
||||
type=Path,
|
||||
help="""Asset repository folder""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
"-l",
|
||||
metavar="NUM_BLEND_FILES",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Limit the number of files to process",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--page",
|
||||
"-p",
|
||||
metavar="ASSETS_PER_PAGE",
|
||||
type=int,
|
||||
default=1000,
|
||||
help="Number of assets per JSON file, set to 0 to disable pagination",
|
||||
)
|
||||
|
||||
|
||||
def _parse_cli_args(arguments_raw: argparse.Namespace) -> CLIArguments:
|
||||
"""Make sure the passed arguments are valid."""
|
||||
|
||||
repository = arguments_raw.repository.absolute()
|
||||
if not repository.is_dir():
|
||||
print(f"Error: Repository specified is not a folder: {repository}")
|
||||
sys.exit(1)
|
||||
|
||||
arguments = CLIArguments(
|
||||
repository=repository,
|
||||
limit=arguments_raw.limit or 0,
|
||||
page_size=arguments_raw.page or 0,
|
||||
)
|
||||
|
||||
return arguments
|
||||
|
||||
|
||||
def _total_files_to_process(arguments: CLIArguments, files_total: int) -> int:
|
||||
if not arguments.limit:
|
||||
return files_total
|
||||
|
||||
return min(arguments.limit, files_total)
|
||||
@@ -0,0 +1,272 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
from . import blender_asset_library_openapi as api_models
|
||||
from . import hashing
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def list_assets(blendfile: Path, asset_library_root: Path) -> tuple[api_models.FileV1, list[api_models.AssetV1]]:
|
||||
# Start by erasing everything from memory.
|
||||
bpy.ops.wm.read_homefile(use_factory_startup=True, use_empty=True, load_ui=False)
|
||||
|
||||
blendfile_info = _blendfile_info(blendfile, asset_library_root)
|
||||
|
||||
# Tell Blender to only load asset data-blocks.
|
||||
with bpy.data.libraries.load(str(blendfile), assets_only=True) as (
|
||||
data_from,
|
||||
data_to,
|
||||
):
|
||||
for attr in dir(data_to):
|
||||
setattr(data_to, attr, getattr(data_from, attr))
|
||||
|
||||
# Convert the Blender version to a string.
|
||||
blend_version = ".".join(map(str, data_from.version))
|
||||
blendfile_info.blender_version = blend_version
|
||||
|
||||
# Get the last modification timestamp of the blend file, to compare against
|
||||
# the thumbnails.
|
||||
thumbnail_dir = blendfile.with_name(blendfile.stem + "_thumbnails")
|
||||
blend_stat = blendfile.stat()
|
||||
|
||||
thumbnail_timestamper = thumbnail_dir / ".last_modified"
|
||||
if thumbnail_timestamper.exists():
|
||||
thumb_mtime = thumbnail_timestamper.stat().st_mtime
|
||||
should_write_thumbnails = abs(blend_stat.st_mtime - thumb_mtime) > 0.001
|
||||
else:
|
||||
should_write_thumbnails = True
|
||||
|
||||
if should_write_thumbnails:
|
||||
# Remove the entire thumbnail tree, so that thumbnails of deleted assets
|
||||
# are also deleted. All thumbnails are going to be re-written anyway.
|
||||
log.debug("thumbnails will be exported to %s", thumbnail_dir)
|
||||
assert thumbnail_dir
|
||||
if Path(thumbnail_dir.root) == thumbnail_dir:
|
||||
raise RuntimeError(f"Refusing to remove a root directory: {thumbnail_dir}")
|
||||
if thumbnail_dir.exists():
|
||||
shutil.rmtree(thumbnail_dir)
|
||||
|
||||
# Collect the asset data.
|
||||
assets: list[api_models.AssetV1] = []
|
||||
for attr in dir(data_to):
|
||||
if attr == 'version':
|
||||
continue
|
||||
datablocks = getattr(data_from, attr)
|
||||
datablocks_assets = _find_assets(
|
||||
asset_library_root,
|
||||
blendfile_info,
|
||||
datablocks,
|
||||
thumbnail_dir,
|
||||
should_write_thumbnails,
|
||||
)
|
||||
assets.extend(datablocks_assets)
|
||||
|
||||
# After processing is done, set the thumbnail dir mtime to that of the
|
||||
# blendfile. By tracking the mtime of the directory itself, not every
|
||||
# individual thumbnail needs to be time-checked.
|
||||
thumbnail_dir.mkdir(exist_ok=True, parents=True)
|
||||
thumbnail_timestamper.touch(exist_ok=True)
|
||||
os.utime(thumbnail_timestamper, (blend_stat.st_atime, blend_stat.st_mtime))
|
||||
|
||||
return blendfile_info, assets
|
||||
|
||||
|
||||
def _find_assets(
|
||||
asset_library_root: Path,
|
||||
file: api_models.FileV1,
|
||||
datablocks: bpy.types.BlendData,
|
||||
thumbnail_dir: Path,
|
||||
should_write_thumbnails: bool,
|
||||
) -> list[api_models.AssetV1]:
|
||||
|
||||
# TODO: when multiple files are supported, take the maximum of the files.
|
||||
bl_versions = api_models.AssetBlenderVersionsV1(
|
||||
min='.'.join(file.blender_version.split('.')[:2]),
|
||||
)
|
||||
|
||||
assets = []
|
||||
for datablock in datablocks:
|
||||
asset_data: bpy.types.AssetData = datablock.asset_data
|
||||
if not asset_data:
|
||||
continue
|
||||
|
||||
thumbnail_path = _thumbnail_path(datablock, thumbnail_dir)
|
||||
if thumbnail_path and should_write_thumbnails:
|
||||
_save_thumbnail(datablock, thumbnail_path)
|
||||
|
||||
if thumbnail_path and thumbnail_path.exists():
|
||||
as_posix = thumbnail_path.relative_to(asset_library_root).as_posix()
|
||||
thumbnail = api_models.URLWithHash(
|
||||
url=urllib.parse.quote(as_posix),
|
||||
hash=hashing.hash_file(thumbnail_path),
|
||||
)
|
||||
else:
|
||||
thumbnail = None
|
||||
|
||||
asset = api_models.AssetV1(
|
||||
name=datablock.name,
|
||||
id_type=datablock.id_type,
|
||||
files=[file.path],
|
||||
thumbnail=thumbnail,
|
||||
bl_versions=bl_versions,
|
||||
meta=_get_asset_meta(asset_data),
|
||||
)
|
||||
|
||||
assets.append(asset)
|
||||
return assets
|
||||
|
||||
|
||||
def _get_asset_meta(asset_data: bpy.types.AssetData) -> api_models.AssetMetadataV1 | None:
|
||||
# Only set the fields that have a value. That way we can detect whether
|
||||
# none of them are set, and prevent the empty metadata from being
|
||||
# included.
|
||||
meta = api_models.AssetMetadataV1()
|
||||
if asset_data.catalog_id and asset_data.catalog_id != "00000000-0000-0000-0000-000000000000":
|
||||
meta.catalog_id = asset_data.catalog_id
|
||||
if asset_data.tags:
|
||||
meta.tags = [tag.name for tag in asset_data.tags]
|
||||
if asset_data.author:
|
||||
meta.author = asset_data.author
|
||||
if asset_data.description:
|
||||
meta.description = asset_data.description
|
||||
if asset_data.license:
|
||||
meta.license = asset_data.license
|
||||
if asset_data.copyright:
|
||||
meta.copyright = asset_data.copyright
|
||||
if asset_data.use_preferred_import_method:
|
||||
meta.preferred_import_method = asset_data.preferred_import_method
|
||||
|
||||
# Convert custom properties.
|
||||
import rna_prop_ui
|
||||
|
||||
custom_props: api_models.CustomPropertiesV1 = []
|
||||
for prop_name, prop_value in asset_data.items():
|
||||
is_array = isinstance(prop_value, rna_prop_ui.ARRAY_TYPES) and len(prop_value) > 0
|
||||
item_value = prop_value[0] if is_array else prop_value
|
||||
|
||||
match item_value:
|
||||
case bool():
|
||||
value_type = api_models.CustomPropertyTypeV1.IDP_BOOL
|
||||
case int():
|
||||
value_type = api_models.CustomPropertyTypeV1.IDP_INT
|
||||
case str():
|
||||
value_type = api_models.CustomPropertyTypeV1.IDP_STRING
|
||||
case float():
|
||||
value_type = api_models.CustomPropertyTypeV1.IDP_FLOAT
|
||||
case _:
|
||||
# Unsupported type, just ignore it.
|
||||
continue
|
||||
|
||||
if is_array:
|
||||
custom_prop = api_models.CustomPropertyV1(
|
||||
name=prop_name,
|
||||
type=api_models.CustomPropertyTypeV1.IDP_ARRAY,
|
||||
value=list(prop_value),
|
||||
itemtype=value_type,
|
||||
)
|
||||
else:
|
||||
custom_prop = api_models.CustomPropertyV1(
|
||||
name=prop_name, type=value_type, value=prop_value
|
||||
)
|
||||
|
||||
custom_props.append(custom_prop)
|
||||
|
||||
if custom_props:
|
||||
meta.properties = custom_props
|
||||
|
||||
if meta == api_models.AssetMetadataV1():
|
||||
return None
|
||||
return meta
|
||||
|
||||
|
||||
def _save_thumbnail(datablock: bpy.types.ID, thumbnail_path: Path) -> None:
|
||||
"""Save the internal preview thumbnail as a WebP image."""
|
||||
|
||||
# Get the preview image size.
|
||||
width: int = datablock.preview.image_size[0]
|
||||
height: int = datablock.preview.image_size[1]
|
||||
|
||||
if not (width > 0 and height > 0):
|
||||
return
|
||||
|
||||
thumbnail_path.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
log.debug("Writing thumbnail: %s", thumbnail_path)
|
||||
try:
|
||||
# Create a new image in Blender to store the preview.
|
||||
image: bpy.types.Image = bpy.data.images.new(
|
||||
thumbnail_path.stem, width, height, alpha=True
|
||||
)
|
||||
|
||||
# Assign the pixel data from the preview to the new image.
|
||||
# image.pixels = [p for p in datablock.preview.image_pixels_float]
|
||||
image.pixels[:] = datablock.preview.image_pixels_float
|
||||
|
||||
# Save the image to disk.
|
||||
image.file_format = "WEBP"
|
||||
image.save(filepath=str(thumbnail_path), quality=80)
|
||||
|
||||
# Remove the image from Blender data after saving to free memory.
|
||||
bpy.data.images.remove(image)
|
||||
except Exception as e:
|
||||
print(f"Failed to save thumbnail for {datablock.name}: {e}")
|
||||
|
||||
|
||||
def _thumbnail_path(datablock: bpy.types.ID, thumbnail_dir: Path) -> Path | None:
|
||||
"""Return the path for this datablock's thumbnail, or None if it has none."""
|
||||
|
||||
if not datablock.preview:
|
||||
return None
|
||||
|
||||
datablock_safe = _name_to_filename(datablock.name)
|
||||
thumbnail_path: Path = (
|
||||
thumbnail_dir / datablock.id_type.title() / f"{datablock_safe}.webp"
|
||||
)
|
||||
|
||||
return thumbnail_path
|
||||
|
||||
|
||||
_re_safe_filename_nonword = re.compile(r'[^\w\s_-]')
|
||||
_re_safe_filename_dashspace = re.compile(r'[-\s]+')
|
||||
|
||||
|
||||
def _name_to_filename(value: str) -> str:
|
||||
"""Convert a string into something that should be safe as filename."""
|
||||
|
||||
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
|
||||
value = _re_safe_filename_nonword.sub('', value.lower())
|
||||
return _re_safe_filename_dashspace.sub('-', value).strip('-_')
|
||||
|
||||
|
||||
def _blendfile_info(filepath: Path, asset_library_root: Path) -> api_models.FileV1:
|
||||
stat = filepath.stat()
|
||||
|
||||
relative_posix = filepath.relative_to(asset_library_root).as_posix()
|
||||
file_url: str | None = urllib.parse.quote(relative_posix)
|
||||
|
||||
if file_url == relative_posix:
|
||||
# Optimization: if the file path is URL-safe, it can be used as the URL
|
||||
# and there is no need to include this URL explicitly.
|
||||
file_url = None
|
||||
|
||||
return api_models.FileV1(
|
||||
path=relative_posix,
|
||||
url=file_url,
|
||||
hash=hashing.hash_file(filepath),
|
||||
size_in_bytes=stat.st_size,
|
||||
blender_version="", # Determined later when the file is opened to find assets.
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import batched
|
||||
|
||||
from . import blender_asset_library_openapi as api_models
|
||||
|
||||
|
||||
def paginate_asset_list(
|
||||
assets: list[api_models.AssetV1],
|
||||
files: list[api_models.FileV1],
|
||||
num_assets_per_page: int = 0,
|
||||
) -> list[api_models.AssetLibraryIndexPageV1]:
|
||||
"""Return a list of asset pages.
|
||||
|
||||
Each page is no longer than `num_assets_per_page` long. If zero, all assets
|
||||
are put in the same page.
|
||||
|
||||
The files listed in each page are determined by the assets on that page.
|
||||
This means that it's possible for multiple pages to list the same file; this
|
||||
occurs when that file contains multiple assets, spread across multiple pages.
|
||||
"""
|
||||
|
||||
# Files are sorted to ensure the generated file is stable (i.e. regenerating produces the same file, and
|
||||
# inserting/removing files produce a small diff).
|
||||
def file_sort_key(file: api_models.FileV1) -> str:
|
||||
return file.path
|
||||
|
||||
if not num_assets_per_page:
|
||||
return [api_models.AssetLibraryIndexPageV1(
|
||||
asset_count=len(assets),
|
||||
assets=assets,
|
||||
file_count=len(files),
|
||||
files=sorted(files, key=file_sort_key),
|
||||
)]
|
||||
|
||||
pages = []
|
||||
for asset_batch in batched(assets, num_assets_per_page):
|
||||
used_file_paths = {
|
||||
file
|
||||
for asset in asset_batch
|
||||
for file in asset.files
|
||||
}
|
||||
file_batch = [file for file in files
|
||||
if file.path in used_file_paths]
|
||||
file_batch.sort(key=file_sort_key)
|
||||
|
||||
page = api_models.AssetLibraryIndexPageV1(
|
||||
asset_count=len(asset_batch),
|
||||
assets=list(asset_batch),
|
||||
file_count=len(file_batch),
|
||||
files=file_batch,
|
||||
)
|
||||
pages.append(page)
|
||||
return pages
|
||||
@@ -0,0 +1,72 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import functools
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from _bpy_internal.assets.remote_library.blender_asset_library_openapi import URLWithHash as _URLWithHash
|
||||
else:
|
||||
_URLWithHash = object
|
||||
|
||||
|
||||
def hash_file(filepath: Path) -> str:
|
||||
"""Computes and returns the hash of the file.
|
||||
|
||||
The returned string is prefixed with the hash type, like "{TYPE}:{HASH}".
|
||||
"""
|
||||
return 'SHA256:' + _sha256_file(filepath)
|
||||
|
||||
|
||||
@functools.lru_cache
|
||||
def _dfhs_storage_path() -> Path:
|
||||
"""Return the storage path of the disk file hash service."""
|
||||
import bpy
|
||||
|
||||
hashes_dir = Path(bpy.app.cachedir) / "{:d}.{:d}/file_hashes".format(*bpy.app.version)
|
||||
hashes_dir.mkdir(parents=True, exist_ok=True)
|
||||
return hashes_dir / "dfhs"
|
||||
|
||||
|
||||
def _sha256_file(filepath: Path) -> str:
|
||||
"""Computes and returns the SHA256 hash of the file."""
|
||||
from _bpy_internal import disk_file_hash_service
|
||||
|
||||
dfhs = disk_file_hash_service.get_service(_dfhs_storage_path())
|
||||
return dfhs.get_hash(filepath, 'sha256')
|
||||
|
||||
|
||||
def url(url_with_hash: _URLWithHash | tuple[str, str]) -> str:
|
||||
"""Return the url, with the hash on the query string.
|
||||
|
||||
>>> url(URLWithHash(url="http://localhost/", hash="sha256:the-hash"))
|
||||
'http://localhost/?hash=the-hash'
|
||||
>>> url(("http://localhost/", "sha256:the-hash"))
|
||||
'http://localhost/?hash=the-hash'
|
||||
"""
|
||||
|
||||
import urllib.parse
|
||||
|
||||
# Get the URL and the hash.
|
||||
if isinstance(url_with_hash, tuple):
|
||||
url, hash_with_type = url_with_hash
|
||||
else:
|
||||
url = url_with_hash.url
|
||||
hash_with_type = url_with_hash.hash
|
||||
|
||||
# Without a hash, it's simple.
|
||||
if not hash_with_type:
|
||||
return url
|
||||
|
||||
# Remove the hash type from the hash string.
|
||||
try:
|
||||
_, hash_value = hash_with_type.split(':', 1)
|
||||
except ValueError:
|
||||
# This means the hash is not in the form '{TYPE}:{HASH}'; just use it as-is.
|
||||
hash_value = hash_with_type
|
||||
|
||||
# Append to the URL with the correct separator.
|
||||
sep = '&' if '?' in url else '?'
|
||||
return url + sep + 'hash=' + urllib.parse.quote(hash_value)
|
||||
@@ -0,0 +1,88 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from _bpy_internal.http import downloader as http_dl
|
||||
|
||||
|
||||
class ExtraFileMetadataProvider(http_dl.MetadataProvider):
|
||||
"""HTTP Metadata provider that can check an extra file.
|
||||
|
||||
This is to support the following file sets:
|
||||
|
||||
- `file.json`: Actual JSON file read by Blender. Is assumed to be validated.
|
||||
- `file-unsafe.json`: JSON file as downloaded. Must be validated before use.
|
||||
- `file-unsafe.json~`: The above file while it's being downloaded. Not yet
|
||||
complete JSON.
|
||||
|
||||
The downloader will get the request to download to `file-unsafe.json`.
|
||||
However, if `file.json` is still fresh (i.e. the HTTP metadata for the URL
|
||||
is applicable to that file), the downloader should be able to do a
|
||||
conditional download (instead of an unconditional one).
|
||||
|
||||
This is implemented as a wrapper for any other MetadataProvider, rather than
|
||||
subclassing a specific one, so that it's independent of the underlying
|
||||
logic.
|
||||
"""
|
||||
|
||||
_wrapped: http_dl.MetadataProvider
|
||||
_logger: logging.Logger
|
||||
|
||||
def __init__(self, wrapped: http_dl.MetadataProvider) -> None:
|
||||
self._wrapped = wrapped
|
||||
self._logger = logging.getLogger(__name__ + ".ExtraFileMetadataProvider")
|
||||
|
||||
def save(self, http_req_descr: http_dl.RequestDescription, meta: http_dl.HTTPMetadata) -> None:
|
||||
self._wrapped.save(http_req_descr, meta)
|
||||
|
||||
def load(self, http_req_descr: http_dl.RequestDescription) -> http_dl.HTTPMetadata | None:
|
||||
return self._wrapped.load(http_req_descr)
|
||||
|
||||
def is_valid(
|
||||
self,
|
||||
meta: http_dl.HTTPMetadata,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_path: Path) -> bool:
|
||||
# This assumes that the download is saved to the "unsafe" location, and
|
||||
# we have to check the metadata on the "safe" location as well.
|
||||
|
||||
if self._wrapped.is_valid(meta, http_req_descr, local_path):
|
||||
self._logger.info("HTTP metadata is valid for %s", local_path)
|
||||
return True
|
||||
|
||||
safe_filename = unsafe_to_safe_filename(local_path)
|
||||
if safe_filename == local_path:
|
||||
# There is no different filename to check, so let's stick to the
|
||||
# result of the first is_valid() call.
|
||||
self._logger.info("HTTP metadata is invalid for %s", local_path)
|
||||
return False
|
||||
|
||||
if self._wrapped.is_valid(meta, http_req_descr, safe_filename):
|
||||
self._logger.info("HTTP metadata is valid for %s", safe_filename)
|
||||
return True
|
||||
|
||||
self._logger.info("HTTP metadata is valid for neither %s nor %s", local_path, safe_filename)
|
||||
return False
|
||||
|
||||
def forget(self, http_req_descr: http_dl.RequestDescription) -> None:
|
||||
self._wrapped.forget(http_req_descr)
|
||||
|
||||
|
||||
def unsafe_to_safe_filename(unsafe_file_path: Path) -> Path:
|
||||
"""path/to/some_file.unsafe-json -> path/to/some_file.json"""
|
||||
# The suffix is changed, and not the stem, so that globs like "*.json" do not see the unsafe files.
|
||||
return unsafe_file_path.with_suffix(unsafe_file_path.suffix.replace('unsafe-', ''))
|
||||
|
||||
|
||||
def safe_to_unsafe_filename(safe_file_path: Path | str) -> Path:
|
||||
"""path/to/some_file.json -> path/to/some_file.unsafe-json"""
|
||||
if isinstance(safe_file_path, str):
|
||||
safe_file_path = Path(safe_file_path)
|
||||
|
||||
# path.suffix includes the leading period, so it's something like ".json".
|
||||
return safe_file_path.with_suffix('.unsafe-' + safe_file_path.suffix[1:])
|
||||
@@ -0,0 +1,61 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""Wrapper around cattrs."""
|
||||
|
||||
__all__ = [
|
||||
"ValidatingParser",
|
||||
"APIModel",
|
||||
]
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
from typing import Any, Type, TypeVar
|
||||
|
||||
import cattrs
|
||||
import cattrs.preconf.json
|
||||
|
||||
from . import blender_asset_library_openapi as api_models
|
||||
|
||||
# There is no common base class for dataclasses, so this type variable will have to act as a stand-in.
|
||||
APIModel = TypeVar("APIModel")
|
||||
|
||||
|
||||
class ValidatingParser:
|
||||
"""Wrapper around cattrs, caching the cattrs converter."""
|
||||
|
||||
_converter: cattrs.preconf.json.JsonConverter
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._converter = cattrs.preconf.json.JsonConverter(omit_if_default=True)
|
||||
|
||||
# Register a custom unstructure hook for the type of `CustomPropertyV1.value`.
|
||||
#
|
||||
# NOTE: this MUST register the 'final' type, and cannot use
|
||||
# `CustomProperties` as an alias for `dict[str, CustomProperty]`. It
|
||||
# won't be found. It also has to include None in the union for some
|
||||
# reason, even though that's not declared in `CustomPropertyV1.value`.
|
||||
#
|
||||
# Basically cattrs told me to register a structure hook for this
|
||||
# specific type, and so that's what I (Sybren) did.
|
||||
self._converter.register_structure_hook(
|
||||
api_models.CustomPropertiesV1 | list[Any] | float | int | str | bool,
|
||||
lambda value, _: value,
|
||||
)
|
||||
|
||||
def parse_and_validate(self, model_class: Type[APIModel], json_payload: bytes | str) -> APIModel:
|
||||
"""Parse & validate the JSON data, returning an instance of the given model class.
|
||||
|
||||
:raises json.JSONDecodeError: if the payload is not formatted as JSON.
|
||||
:raises cattrs.errors.ClassValidationError: if the payload doesn't pass
|
||||
validation and can't be converted to the given model class.
|
||||
"""
|
||||
|
||||
json_doc = json.loads(json_payload)
|
||||
return self._converter.structure(json_doc, model_class)
|
||||
|
||||
def dumps(self, model_instance: Any) -> str:
|
||||
"""Convert the model instance to JSON, returning it as string."""
|
||||
assert dataclasses.is_dataclass(model_instance), f"{model_instance} is not a dataclass"
|
||||
return self._converter.dumps(model_instance, indent=2)
|
||||
@@ -0,0 +1,148 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""Parser for Blender's asset catalog files.
|
||||
|
||||
It would be better if there was an RNA API for this, but for now this is faster
|
||||
to implement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import uuid
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from . import blender_asset_library_openapi as api_models
|
||||
|
||||
SUPPORTED_VERSION = 1
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class AssetCatalog:
|
||||
uuid: str
|
||||
path: PurePosixPath
|
||||
simple_name: str
|
||||
|
||||
|
||||
def parse_catalogs(library_path: Path) -> list[api_models.CatalogV1]:
|
||||
"""Parse all asset catalog files in the asset library.
|
||||
|
||||
Returns a collection of all asset catalogs in the library, as a mapping from
|
||||
UUID to the catalog.
|
||||
|
||||
If there are multiple catalog definition files, they will be merged
|
||||
together.
|
||||
"""
|
||||
# First use a mapping from UUID to the AssetCatalog, to ensure that each
|
||||
# UUID only maps to a single path.
|
||||
catalogs_by_uuid: dict[str, AssetCatalog] = {}
|
||||
|
||||
for file in library_path.rglob('*.cats.txt'):
|
||||
file_cats = _parse_catalog(file)
|
||||
catalogs_by_uuid.update(file_cats)
|
||||
|
||||
# Group catalogs by their path, to make the returned list compatible with
|
||||
# the API model.
|
||||
asset_cats_by_path: dict[PurePosixPath, api_models.CatalogV1] = {}
|
||||
for cat in catalogs_by_uuid.values():
|
||||
try:
|
||||
api_catalog = asset_cats_by_path[cat.path]
|
||||
except KeyError:
|
||||
asset_cats_by_path[cat.path] = api_models.CatalogV1(
|
||||
path=cat.path.as_posix(),
|
||||
uuids=[cat.uuid],
|
||||
simple_name=cat.simple_name,
|
||||
)
|
||||
else:
|
||||
api_catalog.uuids.append(cat.uuid)
|
||||
|
||||
return sorted(asset_cats_by_path.values(), key=lambda api_cat: api_cat.path)
|
||||
|
||||
|
||||
def _parse_catalog(catalog_filepath: Path) -> dict[str, AssetCatalog]:
|
||||
# Mapping from UUID to the AssetCatalog.
|
||||
catalogs: dict[str, AssetCatalog] = {}
|
||||
|
||||
with catalog_filepath.open('r', encoding='utf-8') as infile:
|
||||
for line in infile:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Check the declared version, and simply ignore the file if it is
|
||||
# not supported.
|
||||
if line.startswith('VERSION '):
|
||||
_, version_as_str = line.split(maxsplit=1)
|
||||
if version_as_str != str(SUPPORTED_VERSION):
|
||||
msg = "{}: this version of Blender does not support catalog file version {!r}"
|
||||
print(msg.format(catalog_filepath, version_as_str))
|
||||
return {}
|
||||
continue
|
||||
|
||||
parts = line.split(':', maxsplit=2)
|
||||
if len(parts) < 2:
|
||||
# It's ok for the 'simple name' part to be missing, but if more is missing, this is not a valid file.
|
||||
msg = "{}: this does not seem to be an asset catalog file, ignoring it (line {!r} is not as expected)"
|
||||
print(msg.format(catalog_filepath, line))
|
||||
return {}
|
||||
|
||||
cat = AssetCatalog(
|
||||
uuid=parts[0],
|
||||
path=PurePosixPath(parts[1]),
|
||||
simple_name=parts[2] if len(parts) >= 3 else "",
|
||||
)
|
||||
catalogs[cat.uuid] = cat
|
||||
|
||||
return catalogs
|
||||
|
||||
|
||||
_ASSET_CATS_HEADER = """# This is an Asset Catalog Definition file for Blender.
|
||||
#
|
||||
# Empty lines and lines starting with `#` will be ignored.
|
||||
# The first non-ignored line should be the version indicator.
|
||||
# Other lines are of the format "UUID:catalog/path/for/assets:simple catalog name"
|
||||
#
|
||||
# Remote Asset Library: {library_name!s}
|
||||
|
||||
VERSION 1
|
||||
"""
|
||||
|
||||
|
||||
def write(catalogs: list[api_models.CatalogV1], catalog_filepath: Path,
|
||||
asset_library_meta: api_models.AssetLibraryMeta) -> None:
|
||||
"""Create a catalog file from the list of catalogs."""
|
||||
import re
|
||||
|
||||
# TODO: this really should be using an RNA API.
|
||||
|
||||
# Sanitize the library name, as it should not contain any newlines for the Asset Catalog Definition File to be
|
||||
# valid. To be on the safe side, just collapse all white-space to spaces. Same for colons, those are used as field
|
||||
# separators and shouldn't be included in any of the fields themselves.
|
||||
unwanted_chars_re = re.compile(r'[\s:]+')
|
||||
lib_name = unwanted_chars_re.sub(' ', asset_library_meta.name)
|
||||
|
||||
header = _ASSET_CATS_HEADER.format(library_name=lib_name)
|
||||
|
||||
with catalog_filepath.open("w", encoding="utf8") as catfile:
|
||||
print(header, file=catfile)
|
||||
|
||||
for cat in sorted(catalogs, key=lambda cat: cat.path):
|
||||
for cat_uuid_str in cat.uuids:
|
||||
# Sanitize the catalogs before writing them.
|
||||
try:
|
||||
cat_uuid = uuid.UUID(cat_uuid_str)
|
||||
except ValueError:
|
||||
print("Asset Library has invalid UUID ({uuid!r}) for catalog {path!r}, skipping".format(
|
||||
uuid=cat_uuid_str, path=cat.path))
|
||||
continue
|
||||
|
||||
cat_path = unwanted_chars_re.sub(' ', cat.path)
|
||||
|
||||
if isinstance(cat.simple_name, str):
|
||||
cat_simple_name = unwanted_chars_re.sub(' ', cat.simple_name)
|
||||
else:
|
||||
cat_simple_name = ""
|
||||
|
||||
print("{!s}:{!s}:{!s}".format(cat_uuid, cat_path, cat_simple_name), file=catfile)
|
||||
@@ -0,0 +1,40 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""Shared code for dealing with an asset library index.
|
||||
|
||||
Basically this is shared code between the index generator and index downloader.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
API_VERSION = 1
|
||||
"""The API version supported and produced by this version of Blender."""
|
||||
|
||||
API_VERSIONED_SUBDIR = f"_v{API_VERSION}"
|
||||
"""Sub-directory for all the asset index data except the top level metadata."""
|
||||
|
||||
ASSET_TOP_METADATA_FILENAME = "_asset-library-meta.json"
|
||||
"""Filename for the top-level asset index file.
|
||||
|
||||
This is the entry point for an asset library, and is expected to be at the root
|
||||
of the configured URL for the remote asset library.
|
||||
"""
|
||||
|
||||
ASSET_INDEX_JSON_FILENAME = "asset-index.json"
|
||||
"""Filename for the asset index.
|
||||
|
||||
This is expected to sit in the `API_VERSIONED_SUBDIR`, and reference other files
|
||||
in the same directory.
|
||||
"""
|
||||
|
||||
|
||||
def api_versioned(subpath: Path | str) -> Path:
|
||||
"Return the subpath, prefixed with API_VERSIONED_SUBDIR."
|
||||
return Path(API_VERSIONED_SUBDIR) / subpath
|
||||
|
||||
|
||||
API_VERSIONED_ASSET_INDEX_JSON_PATH = api_versioned(ASSET_INDEX_JSON_FILENAME).as_posix()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
__all__ = (
|
||||
'mutex_lock',
|
||||
'mutex_unlock',
|
||||
)
|
||||
|
||||
|
||||
# Dictionary of local library path to a tuple with:
|
||||
# - lock file handle
|
||||
# - path of the lock file
|
||||
# - unlock function
|
||||
_mutex_locks: dict[Path, tuple[io.IOBase, Path, Callable[[io.IOBase], None]]] = {}
|
||||
|
||||
_registered_atexit = False
|
||||
|
||||
|
||||
def mutex_lock(local_library_path: Path) -> bool:
|
||||
"""Lock the library for syncing.
|
||||
|
||||
Create a file on disk that signals to other Blender instances that this
|
||||
remote asset library is being synced by this Blender.
|
||||
|
||||
This uses approaches from:
|
||||
- https://www.pythontutorials.net/blog/make-sure-only-a-single-instance-of-a-program-is-running/
|
||||
- https://yakking.branchable.com/posts/procrun-2-pidfiles/
|
||||
|
||||
:returns: true if the lock was created successfully, false if some other
|
||||
Blender already locked this library.
|
||||
"""
|
||||
global _registered_atexit
|
||||
|
||||
import atexit
|
||||
import sys
|
||||
|
||||
if not _registered_atexit:
|
||||
atexit.register(_unlock_all)
|
||||
_registered_atexit = True
|
||||
|
||||
# Choose platform-dependent _obtain_lock(file) and _release_lock() functions.
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
def _obtain_lock(file: io.IOBase) -> None:
|
||||
# Lock the first byte of the file (arbitrary choice)
|
||||
msvcrt.locking(file.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
|
||||
def _release_lock(file: io.IOBase) -> None:
|
||||
msvcrt.locking(file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
def _obtain_lock(file: io.IOBase) -> None:
|
||||
fcntl.flock(file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
def _release_lock(file: io.IOBase) -> None:
|
||||
# Closing the file automatically releases the lock.
|
||||
pass
|
||||
|
||||
assert isinstance(local_library_path, Path)
|
||||
assert local_library_path not in _mutex_locks, "Locks are not reentrant"
|
||||
|
||||
lockfile_path = local_library_path / "_sync.lock"
|
||||
|
||||
# It is not suitable here to use an 'exclusive create' ('x' option) here.
|
||||
# That will still create a race condition, with the space between creation
|
||||
# of the file and locking it. So, better to make the existence of the file
|
||||
# meaningless, and only communicate the lock state with an actual file-system
|
||||
# lock.
|
||||
try:
|
||||
# Binary mode (`wb`) is required on Windows, for the locking.
|
||||
lockfile = lockfile_path.open('wb')
|
||||
except OSError:
|
||||
# on Windows, opening a file for writing, while another process already has it open, can fail.
|
||||
# That just means somebody else has ownership of it.
|
||||
return False
|
||||
try:
|
||||
_obtain_lock(lockfile)
|
||||
except OSError:
|
||||
# Lock is already held by another Blender.
|
||||
lockfile.close()
|
||||
return False
|
||||
|
||||
# We have obtained an exclusive lock, which the OS will release when this
|
||||
# process is killed.
|
||||
_mutex_locks[local_library_path] = (lockfile, lockfile_path, _release_lock)
|
||||
return True
|
||||
|
||||
|
||||
def mutex_unlock(local_library_path: Path) -> None:
|
||||
"""Remove the lock created by mutex_lock(local_library_path)."""
|
||||
|
||||
assert isinstance(local_library_path, Path)
|
||||
assert local_library_path in _mutex_locks, "library was not locked"
|
||||
|
||||
lockfile, lockfile_path, release_lock = _mutex_locks[local_library_path]
|
||||
release_lock(lockfile)
|
||||
lockfile.close()
|
||||
|
||||
del _mutex_locks[local_library_path]
|
||||
|
||||
try:
|
||||
lockfile_path.unlink(missing_ok=True)
|
||||
except IOError:
|
||||
# Ignore errors when deleting the file. By now another process may have
|
||||
# recreated it and locked it again.
|
||||
pass
|
||||
|
||||
|
||||
def _unlock_all() -> None:
|
||||
"""Unlock all file mutexes.
|
||||
|
||||
This is automatically called when the Python interpreter exits.
|
||||
|
||||
From the OS perspective it's not necessary, as all locks are automatically
|
||||
released when the process stops. However, Python will complain with a
|
||||
ResourceWarning if any open files are not closed.
|
||||
"""
|
||||
|
||||
for local_library_path in list(_mutex_locks.keys()):
|
||||
mutex_unlock(local_library_path)
|
||||
@@ -0,0 +1,144 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""Service for computing hashes of files on disk.
|
||||
|
||||
The hashes are cached using a storage back-end (currently the SQLite back-end is
|
||||
the only available one). The back-end manages concurrent access, so that
|
||||
multiple Blender instances can use the same cache without conflict.
|
||||
|
||||
Service instances are obtained via `get_service(storage_path)`. They are cached
|
||||
until a new blend file is loaded or Blender exits.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
'get_service',
|
||||
)
|
||||
|
||||
import atexit
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path as _Path
|
||||
from _bpy_internal.disk_file_hash_service.hash_service import DiskFileHashService as _DiskFileHashService
|
||||
else:
|
||||
_Path = object
|
||||
_DiskFileHashService = object
|
||||
|
||||
|
||||
# Mapping from storage path + thread ID to the service instance.
|
||||
_services: dict[tuple[_Path, int], _DiskFileHashService] = {}
|
||||
_services_mutex = threading.Lock()
|
||||
|
||||
|
||||
def get_service(storage_path: _Path) -> _DiskFileHashService:
|
||||
"""Get a disk file hash service that stores its cache on the given path.
|
||||
|
||||
Depending on the back-end (currently there is only the SQLite back-end, and
|
||||
thus there is no choice in which one is used), the storage_path can be used
|
||||
as directory or as file prefix. The SQLite back-end uses
|
||||
`{storage_path}_v{schema_version}.sqlite` as storage.
|
||||
|
||||
Once a DiskFileHashService is constructed, it is cached for future
|
||||
invocations. These cached services are cleaned up when Blender loads another
|
||||
file or when it exits.
|
||||
|
||||
NOTE: DiskFileHashService instances should _NOT_ be used by different
|
||||
threads. When this function is used from a thread other than the main
|
||||
thread, it MUST use `release_service(storage_path)` once the work is done.
|
||||
"""
|
||||
map_key = _map_key(storage_path)
|
||||
|
||||
with _services_mutex:
|
||||
try:
|
||||
return _services[map_key]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
from _bpy_internal.disk_file_hash_service import backend_sqlite, hash_service
|
||||
|
||||
# Construct the service.
|
||||
backend = backend_sqlite.SQLiteBackend(storage_path)
|
||||
service = hash_service.DiskFileHashService(backend)
|
||||
|
||||
# Register cleanup app handlers, if they haven't been registered yet.
|
||||
if _on_file_load_pre not in bpy.app.handlers.load_pre:
|
||||
bpy.app.handlers.load_pre.append(_on_file_load_pre)
|
||||
|
||||
service.open()
|
||||
_services[map_key] = service
|
||||
|
||||
return service
|
||||
|
||||
|
||||
def release_service(storage_path: _Path) -> None:
|
||||
"""Close a DiskFileHashService and release its resources.
|
||||
|
||||
Since DiskFileHashService instances should not be shared across threads,
|
||||
when your thread is done with the service, call this function. This is
|
||||
mandatory, as thread IDs can be reused; not releasing the service when
|
||||
your thread is done with it can cause hard-to-diagnose corruptions when
|
||||
the thread ID is reused by another thread.
|
||||
|
||||
If your DFHS is only ever used from the main thread, it is not mandatory to
|
||||
release it, as that'll automatically happen when a new blend file loads or
|
||||
when Blender exits.
|
||||
|
||||
When there is no known service for the given storage path, this is a no-op.
|
||||
"""
|
||||
map_key = _map_key(storage_path)
|
||||
|
||||
with _services_mutex:
|
||||
try:
|
||||
service = _services.pop(map_key)
|
||||
except KeyError:
|
||||
return
|
||||
service.close()
|
||||
|
||||
|
||||
def _map_key(storage_path: _Path) -> tuple[_Path, int]:
|
||||
thread_id = threading.current_thread().ident
|
||||
assert thread_id is not None, "current thread should be running"
|
||||
return (storage_path, thread_id)
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _on_file_load_pre(_filename: str) -> None:
|
||||
_cleanup_all_services()
|
||||
|
||||
|
||||
@atexit.register
|
||||
def on_blender_exit() -> None:
|
||||
# Named without an underscore, to prevent code checkers from (incorrectly)
|
||||
# thinking this function is never used. VSCode/Pylance needs this.
|
||||
_cleanup_all_services()
|
||||
|
||||
|
||||
def _cleanup_all_services() -> None:
|
||||
"""Close & delete all known services."""
|
||||
|
||||
current_thread_id = threading.current_thread().ident
|
||||
if current_thread_id != threading.main_thread().ident:
|
||||
raise RuntimeError("this function MUST be run from the main thread")
|
||||
|
||||
with _services_mutex:
|
||||
while _services:
|
||||
(_, thread_id), service = _services.popitem()
|
||||
|
||||
# DFHS instances created in a thread MUST be freed by that thread.
|
||||
if thread_id != current_thread_id:
|
||||
print(
|
||||
"WARNING: Disk File Hash Service was created on thread {:d} but not released by that thread".format(thread_id))
|
||||
# Keep running, maybe it can still be freed from this thread, and then we don't leak instances.
|
||||
|
||||
try:
|
||||
service.close()
|
||||
except Exception:
|
||||
# Print the exception, but keep running so that the next service can
|
||||
# be closed.
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,271 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = (
|
||||
'SQLiteBackend',
|
||||
)
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Callable
|
||||
|
||||
from . import types
|
||||
|
||||
|
||||
DB_TIMEOUT_MSEC = 5000 # SQLite busy timeout in milliseconds.
|
||||
DB_SCHEMA_VERSION = 1
|
||||
CREATE_SCHEMA_V1 = """
|
||||
BEGIN EXCLUSIVE;
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
file_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
path TEXT UNIQUE NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS hashes (
|
||||
file_id INTEGER NOT NULL,
|
||||
hash_algo VARCHAR(10) NOT NULL,
|
||||
hexdigest TEXT NOT NULL,
|
||||
size_in_bytes BIGINT NOT NULL,
|
||||
file_stat_mtime FLOAT NOT NULL,
|
||||
last_checked DATETIME NOT NULL,
|
||||
PRIMARY KEY(file_id, hash_algo)
|
||||
FOREIGN KEY(file_id) REFERENCES files(file_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
COMMIT;
|
||||
"""
|
||||
|
||||
# Set to True to print all SQL queries.
|
||||
_DEBUG_QUERIES = False
|
||||
|
||||
|
||||
class SQLiteBackend:
|
||||
"""DiskFileHashBackend implementation using SQLite as storage engine."""
|
||||
|
||||
dbfile_path: Path # Path of the .sqlite file to use.
|
||||
_storage_path: Path # The original storage path, only for the '__repr__' function.
|
||||
|
||||
db_conn_rw: sqlite3.Connection | None = None
|
||||
db_conn_ro: sqlite3.Connection | None = None
|
||||
|
||||
def __init__(self, storage_path: Path) -> None:
|
||||
assert not storage_path.is_dir(), "SQLite back-end expects a directory + file prefix as storage path"
|
||||
assert storage_path.is_absolute(), "SQLite back-end needs an absolute storage path"
|
||||
|
||||
self._storage_path = storage_path
|
||||
self.dbfile_path = storage_path.with_name("{}_v{}.sqlite".format(storage_path.stem, DB_SCHEMA_VERSION))
|
||||
self.db_conn_rw = None
|
||||
self.db_conn_ro = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "{!s}({!r})".format(self.__class__.__qualname__, self._storage_path)
|
||||
|
||||
def open(self) -> None:
|
||||
"""Prepare the back-end for use.
|
||||
|
||||
Create the directory structure & database file, and ensure the schema is as expected.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
self.dbfile_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Open a read-write connection.
|
||||
# Once we upgrade to Python 3.12+, pass `autocommit=False` instead of `isolation_level=None`.
|
||||
self.db_conn_rw = sqlite3.connect(self.dbfile_path, timeout=DB_TIMEOUT_MSEC / 1000, isolation_level=None)
|
||||
if _DEBUG_QUERIES:
|
||||
def callback_rw(query: str) -> None:
|
||||
query = query.replace("\n", "\n ")
|
||||
print(f"SQL/RW: {query}")
|
||||
self.db_conn_rw.set_trace_callback(callback_rw)
|
||||
self._execute_pragmas_on_connect(self.db_conn_rw)
|
||||
|
||||
# Open a read-only connection.
|
||||
try:
|
||||
uri = self.dbfile_path.as_uri() + "?mode=ro"
|
||||
except ValueError as ex:
|
||||
# The ValueError from as_uri() doesn't contain the actual path. Note that
|
||||
# this shouldn't happen, unless the assert from the __init__ function was
|
||||
# disabled (which is possible via a Python CLI argument).
|
||||
raise ValueError("{!s}: {!s}".format(ex, self.dbfile_path))
|
||||
|
||||
# Once we upgrade to Python 3.12+, pass `autocommit=False` instead of `isolation_level=None`.
|
||||
self.db_conn_ro = sqlite3.connect(uri, uri=True, timeout=DB_TIMEOUT_MSEC / 1000, isolation_level=None)
|
||||
if _DEBUG_QUERIES:
|
||||
def callback_ro(query: str) -> None:
|
||||
query = query.replace("\n", "\n ")
|
||||
print(f"SQL/RO: {query}")
|
||||
self.db_conn_ro.set_trace_callback(callback_ro)
|
||||
self._execute_pragmas_on_connect(self.db_conn_ro)
|
||||
|
||||
# Assumption: if the table exists, it should be in the right shape. If
|
||||
# that's not the case, the DB_SCHEMA_VERSION class variable should have
|
||||
# been incremented, and we'd be accessing another database file.
|
||||
#
|
||||
# This does not use our _transaction_rw() function, as the executescript()
|
||||
# function expects the transaction management to be included in the script
|
||||
# itself. It will auto-commit any already-opened transaction, before
|
||||
# running the script.
|
||||
self.db_conn_rw.executescript(CREATE_SCHEMA_V1)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the database connection."""
|
||||
if self.db_conn_ro:
|
||||
self.db_conn_ro.close()
|
||||
self.db_conn_ro = None
|
||||
|
||||
# Close the read-write connection last, otherwise the WAL journal files
|
||||
# will not be check-pointed and removed.
|
||||
if self.db_conn_rw:
|
||||
self.db_conn_rw.close()
|
||||
self.db_conn_rw = None
|
||||
|
||||
def fetch_hash(self, filepath: Path, hash_algorithm: str) -> types.FileHashInfo | None:
|
||||
"""Return the cached hash info of a given file.
|
||||
|
||||
Returns a tuple (hexdigest, file size in bytes, last file mtime).
|
||||
"""
|
||||
|
||||
with self._transaction_ro() as db:
|
||||
cursor = db.execute(
|
||||
"SELECT h.size_in_bytes, h.hexdigest, h.file_stat_mtime " +
|
||||
"FROM files f INNER JOIN hashes h USING (file_id) " +
|
||||
"WHERE f.path=? AND h.hash_algo=?",
|
||||
(str(filepath), hash_algorithm))
|
||||
# The uniqueness constraints ensure there is at most one row.
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
size, hex, mtime = row
|
||||
return types.FileHashInfo(
|
||||
hexhash=hex,
|
||||
file_size_bytes=size,
|
||||
file_stat_mtime=mtime,
|
||||
)
|
||||
|
||||
def store_hash(
|
||||
self,
|
||||
filepath: Path,
|
||||
hash_algorithm: str,
|
||||
hash_info: types.FileHashInfo,
|
||||
pre_write_callback: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
"""Store a pre-computed hash for the given file path. The path has to exist."""
|
||||
now = self._now_string()
|
||||
|
||||
with self._transaction_rw() as db:
|
||||
if pre_write_callback is not None:
|
||||
pre_write_callback()
|
||||
|
||||
# The 'RETURNING file_id' ensures that we know which file ID was
|
||||
# referenced. We can't rely on last_insert_rowid() or
|
||||
# cursor.lastrowid, as that only works on actual INSERT and not on
|
||||
# the 'ON CONFLICT' part. The 'DO UPDATE SET file_id=file_id' is
|
||||
# senseless, but an update is necessary to get the `RETURNING
|
||||
# file_id` to work (it won't return with `ON CONFLICT DO NOTHING`).
|
||||
cursor = db.execute(
|
||||
"INSERT INTO files (path) values (?) ON CONFLICT DO UPDATE SET file_id=file_id RETURNING file_id",
|
||||
(str(filepath),),
|
||||
)
|
||||
file_id = cursor.fetchone()[0]
|
||||
assert file_id, "file_id={!r}".format(file_id)
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO hashes " +
|
||||
"(file_id, hash_algo, hexdigest, size_in_bytes, file_stat_mtime, last_checked) " +
|
||||
"VALUES (:file_id, :hash_algo, :hex, :size, :mtime, :now) ON CONFLICT DO UPDATE " +
|
||||
"SET hexdigest=:hex, size_in_bytes=:size, file_stat_mtime=:mtime, last_checked=:now", {
|
||||
"file_id": file_id,
|
||||
"hash_algo": hash_algorithm,
|
||||
"hex": hash_info.hexhash,
|
||||
"size": hash_info.file_size_bytes,
|
||||
"mtime": hash_info.file_stat_mtime,
|
||||
"now": now,
|
||||
},
|
||||
)
|
||||
|
||||
def mark_hash_as_fresh(self, filepath: Path, hash_algorithm: str) -> None:
|
||||
"""Store that the hash is still considered 'fresh'.
|
||||
|
||||
See `remove_older_than()`.
|
||||
"""
|
||||
|
||||
now = self._now_string()
|
||||
with self._transaction_rw() as db:
|
||||
db.execute(
|
||||
"UPDATE hashes SET last_checked=? " +
|
||||
"WHERE file_id = (SELECT file_id FROM files WHERE path=?) AND hash_algo=?",
|
||||
(now, str(filepath), hash_algorithm))
|
||||
|
||||
def remove_older_than(self, *, days: int) -> None:
|
||||
"""Remove all hash entries that are older than this many days.
|
||||
|
||||
When this removes all known hashes for a file, the file entry itself is
|
||||
also removed.
|
||||
"""
|
||||
older_than = self._now() - datetime.timedelta(days=days)
|
||||
|
||||
with self._transaction_rw() as db:
|
||||
# Delete all old hashes.
|
||||
db.execute("DELETE FROM hashes WHERE last_checked<?",
|
||||
(older_than.isoformat(),))
|
||||
|
||||
# Delete file entries for which there are no hashes known.
|
||||
db.execute(
|
||||
"DELETE FROM files WHERE file_id IN (" +
|
||||
"SELECT f.file_id FROM files f " +
|
||||
"LEFT JOIN hashes h USING (file_id) " +
|
||||
"GROUP BY f.file_id "
|
||||
"HAVING count(h.file_id) == 0" +
|
||||
")")
|
||||
|
||||
def _now(self) -> datetime.datetime:
|
||||
"""Current time, as UTC, in a timezone-aware object."""
|
||||
return datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
|
||||
def _now_string(self) -> str:
|
||||
"""Current time, as UTC, in ISO 6801 notation."""
|
||||
return self._now().isoformat()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _transaction_rw(self) -> Iterator[sqlite3.Connection]:
|
||||
"""Start a read-write transaction.
|
||||
|
||||
The transaction is rolled back when an exception is raised, and
|
||||
committed otherwise.
|
||||
"""
|
||||
assert self.db_conn_rw is not None, "Open the back-end before trying to use it"
|
||||
|
||||
self.db_conn_rw.execute("BEGIN EXCLUSIVE")
|
||||
try:
|
||||
yield self.db_conn_rw
|
||||
except BaseException:
|
||||
self.db_conn_rw.rollback()
|
||||
raise
|
||||
else:
|
||||
self.db_conn_rw.commit()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _transaction_ro(self) -> Iterator[sqlite3.Connection]:
|
||||
"""Start a read-write transaction.
|
||||
|
||||
The transaction is always rolled back, because it shouldn't write
|
||||
anything anyway.
|
||||
"""
|
||||
assert self.db_conn_ro is not None, "Open the back-end before trying to use it"
|
||||
|
||||
self.db_conn_ro.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
yield self.db_conn_ro
|
||||
finally:
|
||||
self.db_conn_ro.rollback()
|
||||
|
||||
def _execute_pragmas_on_connect(self, db_conn: sqlite3.Connection) -> None:
|
||||
db_conn.execute("PRAGMA busy_timeout = {:d}".format(DB_TIMEOUT_MSEC))
|
||||
db_conn.execute("PRAGMA foreign_keys = 1")
|
||||
db_conn.execute("PRAGMA journal_mode = WAL")
|
||||
db_conn.execute("PRAGMA synchronous = normal")
|
||||
@@ -0,0 +1,159 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from . import types
|
||||
|
||||
# Chunk size of the hashing process, in bytes.
|
||||
HASH_BLOCK_SIZE = 1024 * 1024
|
||||
|
||||
# Hashes that have not been 'used' in this many days are removed from the database.
|
||||
# A 'use' means actually storing/updating the hash itself, or seeing that the
|
||||
# stats (file size & mtime) still match the file on disk.
|
||||
HASH_RETAIN_AGE_DAYS = 180
|
||||
|
||||
|
||||
class DiskFileHashService:
|
||||
backend: types.DiskFileHashBackend
|
||||
|
||||
def __init__(self, backend: types.DiskFileHashBackend) -> None:
|
||||
self.backend = backend
|
||||
self._is_open = False
|
||||
|
||||
def open(self) -> None:
|
||||
"""Prepare the service for use."""
|
||||
self.backend.open()
|
||||
self._is_open = True
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the service."""
|
||||
|
||||
if not self._is_open:
|
||||
# Support closing of a never-opened service.
|
||||
return
|
||||
|
||||
# Remove (potentially) outdated hashes. This is done on close, and not
|
||||
# on open, to give Blender the time to query files it needs.
|
||||
#
|
||||
# TODO: as a future improvement, we could investigate (instead of
|
||||
# delete) hashes that are older than X days. If they reference files
|
||||
# that still exist on disk, for which the cached entry is still valid
|
||||
# (given size in bytes & mtime), the cache entry could be marked as
|
||||
# 'freshly checked' instead of removed.
|
||||
self.backend.remove_older_than(days=HASH_RETAIN_AGE_DAYS)
|
||||
self.backend.close()
|
||||
|
||||
self._is_open = False
|
||||
|
||||
def get_hash(self, filepath: Path, hash_algorithm: str) -> str:
|
||||
"""Return the hash of a file on disk."""
|
||||
cached_info = self.backend.fetch_hash(filepath, hash_algorithm)
|
||||
if cached_info:
|
||||
if self._file_stat_matches(filepath, cached_info.file_size_bytes, cached_info.file_stat_mtime):
|
||||
# Cached hash is still fresh.
|
||||
self.backend.mark_hash_as_fresh(filepath, hash_algorithm)
|
||||
return cached_info.hexhash
|
||||
|
||||
# Hash the actual file on disk & store in the back-end.
|
||||
fresh_info = self._hash_file(filepath, hash_algorithm)
|
||||
self.backend.store_hash(filepath, hash_algorithm, fresh_info)
|
||||
return fresh_info.hexhash
|
||||
|
||||
def store_hash(
|
||||
self,
|
||||
filepath: Path,
|
||||
hash_algorithm: str,
|
||||
hash_info: types.FileHashInfo,
|
||||
pre_write_callback: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
"""Store a pre-computed hash for the given file path.
|
||||
|
||||
:param filepath: the file whose hash should be stored. It does not have
|
||||
to exist on disk yet at the moment of calling this function. If the
|
||||
file does not exist, a pre_write_callback function should be given
|
||||
that ensures the file does exist after it has been called.
|
||||
|
||||
:param hash_info: the file's hash, size in bytes, and last-modified
|
||||
timestamp. When pre_write_callback is not None, the caller is
|
||||
trusted to provide the correct information. Otherwise the file size
|
||||
and last-modification timestamp are checked against the file on
|
||||
disk. If they mis-match, a ValueError is raised.
|
||||
|
||||
:param pre_write_callback: if given, the function is called after any
|
||||
lock on the storage back-end has been obtained, and before it is
|
||||
updated. Any exception raised by this callback will abort the
|
||||
storage of the hash.
|
||||
|
||||
This callback function can be used to implement the following:
|
||||
|
||||
- Download a file to a temp location.
|
||||
- Compute its hash while downloading.
|
||||
- After downloading is complete, get the file size & modification time.
|
||||
- Store the hash.
|
||||
- In the pre-write callback function, move the file to its final location.
|
||||
- The Disk File Hashing Service unlocks the back-end.
|
||||
|
||||
This ensures the hash and file on disk are consistent.
|
||||
"""
|
||||
# Sanity check: this function accepts not-currently-valid values, but
|
||||
# only if the callback ensures that they become valid.
|
||||
if pre_write_callback is None and not self._file_stat_matches(
|
||||
filepath, hash_info.file_size_bytes, hash_info.file_stat_mtime):
|
||||
raise ValueError(
|
||||
"to store a hash that does NOT match the file on disk, a pre_write_callback function " +
|
||||
"that ensures the file matches the to-be-stored info, MUST be passed")
|
||||
|
||||
self.backend.store_hash(filepath, hash_algorithm, hash_info, pre_write_callback)
|
||||
|
||||
def file_matches(self, filepath: Path, hash_algorithm: str, hexhash: str, size_in_byes: int) -> bool:
|
||||
"""Check the file on disk, to see if it matches the given properties."""
|
||||
|
||||
# Check the file size first, if it doesn't match we don't have to bother with the hash.
|
||||
stat = filepath.stat()
|
||||
if stat.st_size != size_in_byes:
|
||||
return False
|
||||
|
||||
actual_hash = self.get_hash(filepath, hash_algorithm)
|
||||
|
||||
# The hash value in hex notation is case-insensitive.
|
||||
return actual_hash.lower() == hexhash.lower()
|
||||
|
||||
def _file_stat_matches(self, filepath: Path, size_in_bytes: int, file_stat_mtime: float) -> bool:
|
||||
"""Check whether the file on disk matches this size & timestamp."""
|
||||
try:
|
||||
stat = filepath.stat()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return stat.st_size == size_in_bytes and stat.st_mtime == file_stat_mtime
|
||||
|
||||
def _hash_file(self, filepath: Path, hash_algorithm: str) -> types.FileHashInfo:
|
||||
stat = filepath.stat()
|
||||
|
||||
hasher = self._get_hasher(hash_algorithm)
|
||||
with filepath.open(mode="rb") as infile:
|
||||
while block := infile.read(HASH_BLOCK_SIZE):
|
||||
hasher.update(block)
|
||||
|
||||
return types.FileHashInfo(
|
||||
hexhash=hasher.hexdigest(),
|
||||
file_size_bytes=stat.st_size,
|
||||
file_stat_mtime=stat.st_mtime,
|
||||
)
|
||||
|
||||
def _get_hasher(self, algorithm: str) -> hashlib._Hash:
|
||||
"""Construct a hasher for the given hash algorithm.
|
||||
|
||||
The algorithm should be chosen from hashlib.algorithms_available.
|
||||
"""
|
||||
if algorithm not in hashlib.algorithms_available:
|
||||
available = ", ".join(sorted(hashlib.algorithms_available))
|
||||
raise ValueError("Hash algorithm {!r} not available ({!r})".format(
|
||||
algorithm, available))
|
||||
|
||||
return hashlib.new(algorithm, usedforsecurity=False)
|
||||
@@ -0,0 +1,62 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol, Callable
|
||||
import dataclasses
|
||||
|
||||
|
||||
__all__ = (
|
||||
'DiskFileHashBackend',
|
||||
'FileHashInfo',
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class FileHashInfo:
|
||||
hexhash: str
|
||||
file_size_bytes: int
|
||||
file_stat_mtime: float
|
||||
|
||||
|
||||
class DiskFileHashBackend(Protocol):
|
||||
def open(self) -> None:
|
||||
"""Prepare the back-end for use."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the back-end.
|
||||
|
||||
After calling this, the back-end is not expected to work any more.
|
||||
"""
|
||||
|
||||
def fetch_hash(self, filepath: Path, hash_algorithm: str) -> FileHashInfo | None:
|
||||
"""Return the cached hash info of a given file.
|
||||
|
||||
If no info is cached for this path/algorithm combo, returns None.
|
||||
"""
|
||||
|
||||
def store_hash(
|
||||
self,
|
||||
filepath: Path,
|
||||
hash_algorithm: str,
|
||||
hash_info: FileHashInfo,
|
||||
pre_write_callback: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
"""Store a pre-computed hash for the given file path.
|
||||
|
||||
See DiskFileHashService.store_hash() for an explanation of the parameters.
|
||||
"""
|
||||
|
||||
def mark_hash_as_fresh(self, filepath: Path, hash_algorithm: str) -> None:
|
||||
"""Store that the hash is still considered 'fresh'.
|
||||
|
||||
See `remove_older_than()`.
|
||||
"""
|
||||
|
||||
def remove_older_than(self, *, days: int) -> None:
|
||||
"""Remove all hash entries that are older than this many days.
|
||||
|
||||
When this removes all known hashes for a file, the file entry itself is
|
||||
also removed.
|
||||
"""
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
@@ -0,0 +1,165 @@
|
||||
# SPDX-FileCopyrightText: 2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
JunctionModuleHandle creates a module whose sub-modules are not located
|
||||
in the same directory on the file-system as usual. Instead the sub-modules are
|
||||
added into the package from different locations on the file-system.
|
||||
|
||||
The ``JunctionModuleHandle`` class is used to manipulate sub-modules at run-time.
|
||||
This is needed to implement package management functionality, repositories can be added/removed at run-time.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"JunctionModuleHandle",
|
||||
)
|
||||
|
||||
import sys
|
||||
|
||||
from types import ModuleType
|
||||
from collections.abc import (
|
||||
Sequence,
|
||||
)
|
||||
|
||||
|
||||
def _module_file_set(module: ModuleType, name_full: str) -> None:
|
||||
# File is just an identifier, as this doesn't reference an actual file,
|
||||
# it just needs to be descriptive.
|
||||
module.__name__ = name_full
|
||||
module.__package__ = name_full
|
||||
module.__file__ = "[{:s}]".format(name_full)
|
||||
|
||||
|
||||
def _module_create(
|
||||
name: str,
|
||||
*,
|
||||
parent: ModuleType | None = None,
|
||||
doc: str | None = None,
|
||||
) -> ModuleType:
|
||||
if parent is not None:
|
||||
name_full = parent.__name__ + "." + name
|
||||
else:
|
||||
name_full = name
|
||||
|
||||
module = ModuleType(name, doc)
|
||||
_module_file_set(module, name_full)
|
||||
if parent is not None:
|
||||
setattr(parent, name, module)
|
||||
return module
|
||||
|
||||
|
||||
class JunctionModuleHandle:
|
||||
__slots__ = (
|
||||
"_module_name",
|
||||
"_module",
|
||||
"_submodules",
|
||||
)
|
||||
|
||||
def __init__(self, module_name: str):
|
||||
self._module_name: str = module_name
|
||||
self._module: ModuleType | None = None
|
||||
self._submodules: dict[str, ModuleType] = {}
|
||||
|
||||
def submodule_items(self) -> Sequence[tuple[str, ModuleType]]:
|
||||
return tuple(self._submodules.items())
|
||||
|
||||
def register_module(self) -> ModuleType:
|
||||
"""
|
||||
Register the base module in ``sys.modules``.
|
||||
"""
|
||||
if self._module is not None:
|
||||
raise Exception("Module {!r} already registered!".format(self._module))
|
||||
if self._module_name in sys.modules:
|
||||
raise Exception("Module {:s} already in 'sys.modules'!".format(self._module_name))
|
||||
|
||||
module = _module_create(self._module_name)
|
||||
sys.modules[self._module_name] = module
|
||||
|
||||
# Differentiate this, and allow access to the factory (may be useful).
|
||||
# `module.__module_factory__ = self`
|
||||
|
||||
self._module = module
|
||||
return module
|
||||
|
||||
def unregister_module(self) -> None:
|
||||
"""
|
||||
Unregister the base module in ``sys.modules``.
|
||||
Keep everything except the modules name (allowing re-registration).
|
||||
"""
|
||||
# Cleanup `sys.modules`.
|
||||
sys.modules.pop(self._module_name, None)
|
||||
for submodule_name in self._submodules.keys():
|
||||
sys.modules.pop("{:s}.{:s}".format(self._module_name, submodule_name), None)
|
||||
|
||||
# Remove from self.
|
||||
self._submodules.clear()
|
||||
self._module = None
|
||||
|
||||
def register_submodule(self, submodule_name: str, dirpath: str) -> ModuleType:
|
||||
name_full = self._module_name + "." + submodule_name
|
||||
if self._module is None:
|
||||
raise Exception("Module not registered, cannot register a submodule!")
|
||||
if submodule_name in self._submodules:
|
||||
raise Exception("Module \"{:s}\" already registered!".format(submodule_name))
|
||||
# Register.
|
||||
submodule = _module_create(submodule_name, parent=self._module)
|
||||
sys.modules[name_full] = submodule
|
||||
|
||||
submodule.__path__ = [dirpath]
|
||||
setattr(self._module, submodule_name, submodule)
|
||||
self._submodules[submodule_name] = submodule
|
||||
return submodule
|
||||
|
||||
def unregister_submodule(self, submodule_name: str) -> None:
|
||||
name_full = self._module_name + "." + submodule_name
|
||||
if self._module is None:
|
||||
raise Exception("Module not registered, cannot register a submodule!")
|
||||
# Unregister.
|
||||
submodule = self._submodules.pop(submodule_name, None)
|
||||
if submodule is None:
|
||||
raise Exception("Module \"{:s}\" not registered!".format(submodule_name))
|
||||
delattr(self._module, submodule_name)
|
||||
del sys.modules[name_full]
|
||||
|
||||
# Remove all sub-modules, to prevent them being reused in the future.
|
||||
#
|
||||
# While it might not seem like a problem to keep these around it means if a module
|
||||
# with the same name is registered later, importing sub-modules uses the cached values
|
||||
# from `sys.modules` and does *not* assign the module to the name-space of the new `submodule`.
|
||||
# This isn't exactly a bug, it's often assumed that inspecting a module
|
||||
# is a way to find its sub-modules, using `dir(submodule)` for example.
|
||||
# For more technical example `sys.modules["foo.bar"] == sys.modules["foo"].bar`
|
||||
# which can fail with and attribute error unless the modules are cleared here.
|
||||
#
|
||||
# An alternative solution could be re-attach sub-modules to the modules name-space when its re-registered.
|
||||
# This has some advantages since the module doesn't have to be re-imported however it has the down
|
||||
# side that stale data would be kept in `sys.modules` unnecessarily in many cases.
|
||||
name_full_prefix = name_full + "."
|
||||
submodule_name_list = [
|
||||
submodule_name for submodule_name in sys.modules.keys()
|
||||
if submodule_name.startswith(name_full_prefix)
|
||||
]
|
||||
for submodule_name in submodule_name_list:
|
||||
del sys.modules[submodule_name]
|
||||
|
||||
def rename_submodule(self, submodule_name_src: str, submodule_name_dst: str) -> None:
|
||||
name_full_prev = self._module_name + "." + submodule_name_src
|
||||
name_full_next = self._module_name + "." + submodule_name_dst
|
||||
|
||||
submodule = self._submodules.pop(submodule_name_src)
|
||||
self._submodules[submodule_name_dst] = submodule
|
||||
|
||||
delattr(self._module, submodule_name_src)
|
||||
setattr(self._module, submodule_name_dst, submodule)
|
||||
|
||||
_module_file_set(submodule, name_full_next)
|
||||
|
||||
del sys.modules[name_full_prev]
|
||||
sys.modules[name_full_next] = submodule
|
||||
|
||||
def rename_directory(self, submodule_name: str, dirpath: str) -> None:
|
||||
# TODO: how to deal with existing loaded modules?
|
||||
# In practice this is mostly users setting up directories for the first time.
|
||||
submodule = self._submodules[submodule_name]
|
||||
submodule.__path__ = [dirpath]
|
||||
@@ -0,0 +1,16 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# The purpose of this list is to present the permissions to be picked up by translation.
|
||||
|
||||
# The initial list of permissions is the one defined in the manifest schema
|
||||
# (https://developer.blender.org/docs/features/extensions/schema/).
|
||||
|
||||
permissions = [
|
||||
"camera",
|
||||
"clipboard",
|
||||
"files",
|
||||
"microphone",
|
||||
"network",
|
||||
]
|
||||
@@ -0,0 +1,417 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Foundation
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Schedule files for later removal, needed for situations where files are locked.
|
||||
#
|
||||
# This is mainly a workaround for WIN32 error where an add-on DLL
|
||||
# is considered *used* making it impossible to remove.
|
||||
#
|
||||
# This is also used on other systems as permissions can also prevent sub-directories from removed.
|
||||
# In this case renaming can make way the path to be replaced however it doesn't address
|
||||
# the problem of the "stale" path failing to be removed.
|
||||
# The user would need to change the permissions in this case (although this really a corner case).
|
||||
__all__ = (
|
||||
"StaleFiles",
|
||||
)
|
||||
|
||||
|
||||
from collections.abc import (
|
||||
Sequence,
|
||||
)
|
||||
|
||||
# The stale file-format is very simple and works as follows.
|
||||
#
|
||||
# - Every line references a path relative to the stale file.
|
||||
# - Paths must always references files within this directory
|
||||
# (anything else must be ignored).
|
||||
# - Paths must always use forward slashes (even on WIN32).
|
||||
# This is done since a repository may be accessed from different systems.
|
||||
# - Paths must end with a newline `\n`.
|
||||
#
|
||||
# Further notes:
|
||||
# - Corrupted "stale" files must be handled gracefully (it may be random bytes).
|
||||
# - Non UTF8 characters in paths are supported via `surrogateescape`.
|
||||
# - File names containing newlines are *not* supported.
|
||||
|
||||
|
||||
class StaleFiles:
|
||||
__slots__ = (
|
||||
# Files outside of this directory must *never* be removed.
|
||||
"_base_directory",
|
||||
# The name (within `_base_directory`) to load/store paths.
|
||||
"_stale_filename",
|
||||
# Stale paths relative to `_base_directory`.
|
||||
"_paths",
|
||||
# When true, print extra debug output.
|
||||
"_debug",
|
||||
# Store the cache index per-directory, avoids looking up an index every time a stale name needs to be created.
|
||||
"_index_cache",
|
||||
# True when the run-time state is different to the on-disk state.
|
||||
"_is_modified",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_directory: str,
|
||||
*,
|
||||
stale_filename: str,
|
||||
debug: bool = False,
|
||||
):
|
||||
import os
|
||||
from os import sep
|
||||
assert base_directory not in ("", ".", "..")
|
||||
# NOTE: on WIN32 `normpath` won't remove the trailing `sep`,
|
||||
# it's important to add only if it's not there.
|
||||
base_directory = os.path.normpath(base_directory)
|
||||
self._base_directory = base_directory if base_directory.endswith(sep) else (base_directory + sep)
|
||||
self._stale_filename = stale_filename
|
||||
self._paths: list[str] = []
|
||||
self._debug: bool = debug
|
||||
|
||||
self._index_cache: dict[str, int] = {}
|
||||
self._is_modified: bool = True
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not bool(self._paths)
|
||||
|
||||
def is_modified(self) -> bool:
|
||||
return self._is_modified
|
||||
|
||||
def state_load(self, *, check_exists: bool) -> None:
|
||||
import contextlib
|
||||
import os
|
||||
from os import sep
|
||||
|
||||
base_directory = self._base_directory
|
||||
paths = self._paths
|
||||
debug = self._debug
|
||||
|
||||
assert base_directory.endswith(sep)
|
||||
# Don't support loading multiple times or running again after adding files.
|
||||
assert len(paths) == 0
|
||||
|
||||
stale_filepath = os.path.join(base_directory, self._stale_filename)
|
||||
|
||||
line_count = 0
|
||||
|
||||
# Set here before early exit.
|
||||
# Assume modified so any corrupt causes a re-write.
|
||||
self._is_modified = True
|
||||
|
||||
try:
|
||||
# pylint: disable-next=consider-using-with
|
||||
fh_context = open(stale_filepath, "r", encoding="utf8", errors="surrogateescape")
|
||||
except FileNotFoundError:
|
||||
self._is_modified = False
|
||||
return
|
||||
except Exception as ex:
|
||||
if debug:
|
||||
print(base_directory, "error opening file for read", str(ex))
|
||||
return
|
||||
|
||||
with contextlib.closing(fh_context) as fh:
|
||||
fh_iter = iter(fh)
|
||||
while True:
|
||||
try:
|
||||
path = next(fh_iter)
|
||||
except StopIteration:
|
||||
break
|
||||
except Exception as ex:
|
||||
if debug:
|
||||
print(base_directory, "error reading line", str(ex))
|
||||
break
|
||||
|
||||
line_count += 1
|
||||
# Not expected, file may be truncated.
|
||||
if not path.endswith("\n"):
|
||||
if debug:
|
||||
print(base_directory, "expected line endings on each line")
|
||||
continue
|
||||
path = path[:-1]
|
||||
# Not expected but harmless, ignore if it does.
|
||||
if not path:
|
||||
if debug:
|
||||
print(base_directory, "expected line not to be empty")
|
||||
continue
|
||||
|
||||
path_abs = base_directory + (path if sep == "/" else path.replace("/", "\\"))
|
||||
|
||||
if check_exists:
|
||||
# Harmless, somehow the file was removed.
|
||||
if not os.path.exists(path_abs):
|
||||
continue
|
||||
|
||||
path_abs = os.path.normpath(path_abs)
|
||||
# Not expected, ensure under *no* conditions paths outside this directory are removed.
|
||||
if not path_abs.startswith(base_directory):
|
||||
if debug:
|
||||
print(base_directory, "stale file points to parent path (unexpected but harmless)", repr(path))
|
||||
continue
|
||||
|
||||
# Ensure the `base_directory` & `path_abs` they are not the same.
|
||||
# One could be forgiven for thinking they must never be the same since `path`
|
||||
# is known not be an empty string, one would be mistaken!
|
||||
# WIN32 which considers `C:\path\` the same as `C:\path\. ` to be the same.
|
||||
# Therefor, literal lines containing any combination of trailing full-stop
|
||||
# or space characters would be considered files that cannot be removed.
|
||||
# While this should never under normal conditions happen,
|
||||
# guarantee that stale file removal *never* removes anything it should not,
|
||||
# including situations when random bytes are written into this file
|
||||
# (except in the case the random bytes happen to match a patch - which can't be avoided).
|
||||
#
|
||||
# If this ever did happen besides potentially trying to remove `base_directory`,
|
||||
# this path could be treated as a file which could not be removed and queued for
|
||||
# removal again causing a single space (for example) to be left in the stale file,
|
||||
# trying to be removed every startup and failing.
|
||||
# Avoid all these issues by checking the path doesn't resolve to being the same path as it's parent.
|
||||
is_same = False
|
||||
try:
|
||||
is_same = os.path.samefile(base_directory, path_abs)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as ex:
|
||||
if debug:
|
||||
print(base_directory, "error checking the same path", str(ex))
|
||||
|
||||
if is_same:
|
||||
if debug:
|
||||
print(base_directory, "path results to it's parent", repr(path))
|
||||
continue
|
||||
|
||||
# NOTE: duplicates are not checked, while they aren't expected, duplicates won't cause errors.
|
||||
paths.append(path)
|
||||
|
||||
self._is_modified = len(paths) != line_count
|
||||
|
||||
def state_store(self, *, check_exists: bool) -> None:
|
||||
import contextlib
|
||||
import os
|
||||
from os import sep
|
||||
|
||||
base_directory = self._base_directory
|
||||
debug = self._debug
|
||||
|
||||
stale_filepath = os.path.join(base_directory, self._stale_filename)
|
||||
|
||||
if not self._paths:
|
||||
self._is_modified = False
|
||||
try:
|
||||
os.remove(stale_filepath)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as ex:
|
||||
if debug:
|
||||
print(base_directory, "failed to remove!", str(ex))
|
||||
self._is_modified = True
|
||||
|
||||
return
|
||||
|
||||
try:
|
||||
# pylint: disable-next=consider-using-with
|
||||
fh_context = open(stale_filepath, "w", encoding="utf8", errors="surrogateescape")
|
||||
except Exception as ex:
|
||||
if debug:
|
||||
print(base_directory, "error opening file for write", str(ex))
|
||||
self._is_modified = True
|
||||
return
|
||||
|
||||
# Assume success, any errors can set to true.
|
||||
is_modified = False
|
||||
|
||||
with contextlib.closing(fh_context) as fh:
|
||||
for path in self._paths:
|
||||
if check_exists:
|
||||
path_abs = base_directory + (path if sep == "/" else path.replace("/", "\\"))
|
||||
# Harmless, somehow the file was removed.
|
||||
if not os.path.exists(path_abs):
|
||||
continue
|
||||
|
||||
try:
|
||||
fh.write(path + "\n")
|
||||
except Exception as ex:
|
||||
if debug:
|
||||
print(base_directory, "failed to write path", str(ex))
|
||||
is_modified = True
|
||||
break
|
||||
|
||||
self._is_modified = is_modified
|
||||
|
||||
def state_remove_all(self) -> bool:
|
||||
import stat
|
||||
import shutil
|
||||
import os
|
||||
|
||||
from os import sep
|
||||
|
||||
base_directory = self._base_directory
|
||||
debug = self._debug
|
||||
|
||||
paths_next = []
|
||||
|
||||
for path in self._paths:
|
||||
path_abs = base_directory + (path if sep == "/" else path.replace("/", "\\"))
|
||||
path_abs = os.path.normpath(path_abs)
|
||||
|
||||
# Should be unreachable, extra paranoid check so we *never*
|
||||
# recursively remove anything outside of the base directory.
|
||||
if not path_abs.startswith(base_directory):
|
||||
print("Internal error detected attempting to remove file outside of:", base_directory)
|
||||
continue
|
||||
|
||||
try:
|
||||
st = os.stat(path_abs)
|
||||
except FileNotFoundError:
|
||||
# Not a problem if it's already removed.
|
||||
continue
|
||||
except Exception as ex:
|
||||
if debug:
|
||||
print(base_directory, "failed to stat file", path, str(ex))
|
||||
continue
|
||||
|
||||
if stat.S_ISDIR(st.st_mode):
|
||||
try:
|
||||
shutil.rmtree(path_abs)
|
||||
except Exception as ex:
|
||||
# May be necessary with links.
|
||||
try:
|
||||
os.remove(path_abs)
|
||||
except Exception:
|
||||
if debug:
|
||||
print(base_directory, "failed to remove dir", path, str(ex))
|
||||
else:
|
||||
try:
|
||||
os.remove(path_abs)
|
||||
except Exception as ex:
|
||||
if debug:
|
||||
print(base_directory, "failed to remove file", path, str(ex))
|
||||
|
||||
# Failed to remove, add back to the list.
|
||||
if os.path.exists(path_abs):
|
||||
paths_next.append(path)
|
||||
|
||||
if len(self._paths) == len(paths_next):
|
||||
return False
|
||||
|
||||
self._is_modified = True
|
||||
self._paths[:] = paths_next
|
||||
return True
|
||||
|
||||
def state_load_add_and_store(
|
||||
self,
|
||||
*,
|
||||
# A sequence of absolute paths within `_base_directory`.
|
||||
paths: Sequence[str],
|
||||
) -> bool:
|
||||
# Convenience function for a common operation.
|
||||
# Return true when one or more items from "paths" were added to the "state".
|
||||
|
||||
self.state_load(check_exists=True)
|
||||
if not self.is_empty():
|
||||
self.state_remove_all()
|
||||
|
||||
result = False
|
||||
for path_abs in paths:
|
||||
self.filepath_add(path_abs, rename=True)
|
||||
result = True
|
||||
|
||||
if self.is_modified():
|
||||
self.state_store(check_exists=False)
|
||||
|
||||
return result
|
||||
|
||||
def state_load_remove_and_store(
|
||||
self,
|
||||
*,
|
||||
# A sequence of absolute paths within `_base_directory`.
|
||||
paths: Sequence[str],
|
||||
) -> bool:
|
||||
# Convenience function for a common operation.
|
||||
# Return true when one or more items from "paths" were removed from the "state".
|
||||
|
||||
self.state_load(check_exists=False)
|
||||
# Accounts for the common case where nothing has been marked for removal.
|
||||
if not self._paths:
|
||||
return False
|
||||
|
||||
paths_remove_canonical = {
|
||||
self._filepath_relative_and_canonicalize(path_abs) for path_abs in paths
|
||||
if self._filepath_relative_test(path_abs)
|
||||
}
|
||||
|
||||
paths_next = [path for path in self._paths if path not in paths_remove_canonical]
|
||||
if len(self._paths) == len(paths_next):
|
||||
return False
|
||||
|
||||
self._paths[:] = paths_next
|
||||
self._is_modified = True
|
||||
|
||||
self.state_store(check_exists=False)
|
||||
|
||||
return True
|
||||
|
||||
def _filepath_relative_test(self, path_abs: str) -> bool:
|
||||
debug = self._debug
|
||||
base_directory = self._base_directory
|
||||
if not path_abs.startswith(base_directory):
|
||||
if debug:
|
||||
print(base_directory, "is not a sub-directory", path_abs)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _filepath_relative_and_canonicalize(self, path_abs: str) -> str:
|
||||
from os import sep
|
||||
|
||||
assert self._filepath_relative_test(path_abs)
|
||||
|
||||
path = path_abs[len(self._base_directory):].lstrip(sep)
|
||||
if sep == "\\":
|
||||
path = path.replace("\\", "/")
|
||||
return path
|
||||
|
||||
def _filepath_rename_to_stale(self, path_abs: str) -> str:
|
||||
import os
|
||||
|
||||
base_directory = self._base_directory
|
||||
debug = self._debug
|
||||
|
||||
# These need not necessarily match, it could be optional.
|
||||
prefix = self._stale_filename
|
||||
|
||||
dirpath = os.path.dirname(path_abs)
|
||||
stale_index = self._index_cache.get(dirpath, 1)
|
||||
while True:
|
||||
path_abs_stale = os.path.join(dirpath, "{:s}{:04x}".format(prefix, stale_index))
|
||||
if not os.path.exists(path_abs_stale):
|
||||
break
|
||||
stale_index += 1
|
||||
|
||||
rename_ok = False
|
||||
try:
|
||||
os.rename(path_abs, path_abs_stale)
|
||||
rename_ok = True
|
||||
except Exception as ex:
|
||||
if debug:
|
||||
print(base_directory, "failed to rename path", str(ex))
|
||||
|
||||
if rename_ok:
|
||||
self._index_cache[dirpath] = stale_index + 1
|
||||
else:
|
||||
# Failed to rename, make the previous name stale as we have no better options.
|
||||
path_abs_stale = path_abs
|
||||
if debug:
|
||||
print("failed to rename:", path_abs)
|
||||
|
||||
return path_abs_stale
|
||||
|
||||
def filepath_add(self, path_abs: str, *, rename: bool) -> bool:
|
||||
if not self._filepath_relative_test(path_abs):
|
||||
return False
|
||||
|
||||
if rename:
|
||||
path_abs = self._filepath_rename_to_stale(path_abs)
|
||||
path = self._filepath_relative_and_canonicalize(path_abs)
|
||||
|
||||
self._is_modified = True
|
||||
self._paths.append(path)
|
||||
return True
|
||||
@@ -0,0 +1,54 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# The purpose of this list is to present the tags to be picked up by translation.
|
||||
# Blender itself will show all the available tags from all the servers.
|
||||
|
||||
# The initial list of tags are the ones used by Blender Extensions Platforms (https://extensions.blender.org).
|
||||
# Other platforms can send PRs to extend this list further.
|
||||
|
||||
addons = {
|
||||
"All", # Added automatically for legacy add-ons without a category.
|
||||
"3D View",
|
||||
"Add Curve",
|
||||
"Add Mesh",
|
||||
"Animation",
|
||||
"Bake",
|
||||
"Camera",
|
||||
"Compositing",
|
||||
"Development",
|
||||
"Game Engine",
|
||||
"Geometry Nodes",
|
||||
"Grease Pencil",
|
||||
"Import-Export",
|
||||
"Lighting",
|
||||
"Material",
|
||||
"Mesh",
|
||||
"Modeling",
|
||||
"Node",
|
||||
"Object",
|
||||
"Paint",
|
||||
"Physics",
|
||||
"Pipeline",
|
||||
"Render",
|
||||
"Rigging",
|
||||
"Scene",
|
||||
"Sculpt",
|
||||
"Sequencer",
|
||||
"System",
|
||||
"Text Editor",
|
||||
"Tracking",
|
||||
"User Interface",
|
||||
"UV",
|
||||
}
|
||||
|
||||
themes = {
|
||||
"Accessibility",
|
||||
"Colorful",
|
||||
"Dark",
|
||||
"High Contrast",
|
||||
"Inspired By",
|
||||
"Light",
|
||||
"Print",
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Foundation
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Ref: https://peps.python.org/pep-0491/
|
||||
# Deferred but seems to include valid info for existing wheels.
|
||||
|
||||
"""
|
||||
This module takes wheels and applies them to a "managed" destination directory.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"apply_action",
|
||||
)
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
from collections.abc import (
|
||||
Callable,
|
||||
Iterator,
|
||||
)
|
||||
|
||||
WheelSource = tuple[
|
||||
# Key - doesn't matter what this is... it's just a handle.
|
||||
str,
|
||||
# A list of absolute wheel file-paths.
|
||||
list[str],
|
||||
]
|
||||
|
||||
|
||||
def _read_records_csv(filepath: str) -> list[list[str]]:
|
||||
import csv
|
||||
with open(filepath, encoding="utf8", errors="surrogateescape") as fh:
|
||||
return list(csv.reader(fh.read().splitlines()))
|
||||
|
||||
|
||||
def _wheels_from_dir(dirpath: str) -> tuple[
|
||||
# The key is:
|
||||
# wheel_id
|
||||
# The values are:
|
||||
# Top level directories.
|
||||
dict[str, list[str]],
|
||||
# Unknown paths.
|
||||
list[str],
|
||||
]:
|
||||
result: dict[str, list[str]] = {}
|
||||
paths_unused: set[str] = set()
|
||||
|
||||
if not os.path.exists(dirpath):
|
||||
return result, list(paths_unused)
|
||||
|
||||
for entry in os.scandir(dirpath):
|
||||
name = entry.name
|
||||
paths_unused.add(name)
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
# TODO: is this part of the spec?
|
||||
name = entry.name
|
||||
if not name.endswith("-info"):
|
||||
continue
|
||||
filepath_record = os.path.join(entry.path, "RECORD")
|
||||
if not os.path.exists(filepath_record):
|
||||
continue
|
||||
|
||||
record_rows = _read_records_csv(filepath_record)
|
||||
|
||||
# Build top-level paths.
|
||||
toplevel_paths_set: set[str] = set()
|
||||
for row in record_rows:
|
||||
if not row:
|
||||
continue
|
||||
path_text = row[0]
|
||||
# Ensure paths separator is compatible.
|
||||
path_text = path_text.replace("\\", "/")
|
||||
# Ensure double slashes don't cause issues or "/./" doesn't complicate checking the head of the path.
|
||||
path_split = [
|
||||
elem for elem in path_text.split("/")
|
||||
if elem not in {"", "."}
|
||||
]
|
||||
if not path_split:
|
||||
continue
|
||||
# These wont have been extracted.
|
||||
if path_split[0] in {"..", name}:
|
||||
continue
|
||||
|
||||
toplevel_paths_set.add(path_split[0])
|
||||
|
||||
# Some wheels contain `{name}.libs` which are *not* listed in `RECORD`.
|
||||
# Always add the path, the value will be skipped if it's missing.
|
||||
toplevel_paths_set.add(os.path.join(dirpath, name.partition("-")[0] + ".libs"))
|
||||
|
||||
result[name] = list(sorted(toplevel_paths_set))
|
||||
del toplevel_paths_set
|
||||
|
||||
for wheel_name, toplevel_paths in result.items():
|
||||
paths_unused.discard(wheel_name)
|
||||
for name in toplevel_paths:
|
||||
paths_unused.discard(name)
|
||||
|
||||
paths_unused_list = list(sorted(paths_unused))
|
||||
|
||||
return result, paths_unused_list
|
||||
|
||||
|
||||
def _wheel_info_dir_from_zip(filepath_wheel: str) -> tuple[str, list[str]] | None:
|
||||
"""
|
||||
Return:
|
||||
- The "*-info" directory name which contains meta-data.
|
||||
- The top-level path list (excluding "..").
|
||||
"""
|
||||
dir_info = ""
|
||||
toplevel_paths: set[str] = set()
|
||||
|
||||
with zipfile.ZipFile(filepath_wheel, mode="r") as zip_fh:
|
||||
# This file will always exist.
|
||||
for filepath_rel in zip_fh.namelist():
|
||||
path_split = [
|
||||
elem for elem in filepath_rel.split("/")
|
||||
if elem not in {"", "."}
|
||||
]
|
||||
if not path_split:
|
||||
continue
|
||||
if path_split[0] == "..":
|
||||
continue
|
||||
|
||||
if len(path_split) == 2:
|
||||
if path_split[1].upper() == "RECORD":
|
||||
if path_split[0].endswith("-info"):
|
||||
dir_info = path_split[0]
|
||||
|
||||
toplevel_paths.add(path_split[0])
|
||||
|
||||
if dir_info == "":
|
||||
return None
|
||||
toplevel_paths.discard(dir_info)
|
||||
toplevel_paths_list = list(sorted(toplevel_paths))
|
||||
return dir_info, toplevel_paths_list
|
||||
|
||||
|
||||
def _rmtree_safe(dir_remove: str, expected_root: str) -> Exception | None:
|
||||
if not dir_remove.startswith(expected_root):
|
||||
raise Exception("Expected prefix not found")
|
||||
|
||||
ex_result = None
|
||||
|
||||
def on_exc(*args) -> None: # type: ignore
|
||||
nonlocal ex_result
|
||||
print("Failed to remove:", args)
|
||||
ex_result = args[2]
|
||||
|
||||
shutil.rmtree(dir_remove, onexc=on_exc)
|
||||
|
||||
return ex_result
|
||||
|
||||
|
||||
def _remove_safe(file_remove: str) -> Exception | None:
|
||||
ex_result = None
|
||||
|
||||
try:
|
||||
os.remove(file_remove)
|
||||
except Exception as ex:
|
||||
ex_result = ex
|
||||
|
||||
return ex_result
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Support for Wheel: Binary distribution format
|
||||
|
||||
def _wheel_parse_key_value(data: bytes) -> dict[bytes, bytes]:
|
||||
# Parse: `{module}.dist-info/WHEEL` format, parse it inline as
|
||||
# this doesn't seem to use an existing specification, it's simply key/value pairs.
|
||||
result = {}
|
||||
for line in data.split(b"\n"):
|
||||
key, sep, value = line.partition(b":")
|
||||
if not sep:
|
||||
continue
|
||||
if not key:
|
||||
continue
|
||||
result[key.strip()] = value.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _wheel_record_csv_remap(record_data: str, record_path_map: dict[str, str]) -> bytes:
|
||||
import csv
|
||||
from io import StringIO
|
||||
|
||||
lines_remap = []
|
||||
|
||||
for line in csv.reader(StringIO(record_data, newline="")):
|
||||
# It's expected to be 3, in this case we only care about the first element (the path),
|
||||
# however, if there are fewer items, this may be malformed or some unknown future format.
|
||||
# - Only handle lines containing 3 elements.
|
||||
# - Only manipulate the first element.
|
||||
if len(line) < 3:
|
||||
continue
|
||||
# Items 1 and 2 are hash_sum & size respectively.
|
||||
# If the files need to be modified these will need to be updated.
|
||||
path = line[0]
|
||||
if (path_remap := record_path_map.get(path)) is not None:
|
||||
print(path_remap)
|
||||
line = [path_remap, *line[0]]
|
||||
|
||||
lines_remap.append(line)
|
||||
|
||||
data = StringIO()
|
||||
writer = csv.writer(data, delimiter=",", quotechar='"', lineterminator="\n")
|
||||
writer.writerows(lines_remap)
|
||||
return data.getvalue().encode("utf8")
|
||||
|
||||
|
||||
def _wheel_zipfile_normalize(
|
||||
zip_fh: zipfile.ZipFile,
|
||||
error_fn: Callable[[Exception], None],
|
||||
) -> dict[str, bytes] | None:
|
||||
"""
|
||||
Modify the ZIP file to account for Python's binary format.
|
||||
"""
|
||||
|
||||
member_dict = {}
|
||||
files_to_find = (".dist-info/WHEEL", ".dist-info/RECORD")
|
||||
|
||||
for member in zip_fh.infolist():
|
||||
filename_orig = member.filename
|
||||
if (
|
||||
filename_orig.endswith(files_to_find) and
|
||||
# Unlikely but possible the names also exist in nested directories.
|
||||
(filename_orig.count("/") == 1)
|
||||
):
|
||||
member_dict[os.path.basename(filename_orig)] = member
|
||||
if len(member_dict) == len(files_to_find):
|
||||
break
|
||||
|
||||
if (
|
||||
((member_wheel := member_dict.get("WHEEL")) is None) or
|
||||
((member_record := member_dict.get("RECORD")) is None)
|
||||
):
|
||||
return None
|
||||
|
||||
try:
|
||||
wheel_data = zip_fh.read(member_wheel.filename)
|
||||
except Exception as ex:
|
||||
error_fn(ex)
|
||||
return None
|
||||
|
||||
wheel_key_values = _wheel_parse_key_value(wheel_data)
|
||||
if wheel_key_values.get(b"Root-Is-Purelib", b"true").lower() != b"false":
|
||||
return None
|
||||
del wheel_key_values
|
||||
|
||||
# The setting has been found: `Root-Is-Purelib: false`.
|
||||
# This requires the wheel to be mangled.
|
||||
#
|
||||
# - `{module-XXX}.dist-info/*` will have a:
|
||||
# `{module-XXX}.data/purelib/`
|
||||
# - For a full list see:
|
||||
# https://docs.python.org/3/library/sysconfig.html#installation-paths
|
||||
#
|
||||
# Note that PIP's `wheel` package has a `wheel/wheelfile.py` file which is a useful reference.
|
||||
|
||||
assert member_wheel.filename.endswith("/WHEEL")
|
||||
dirpath_dist_info = member_wheel.filename.removesuffix("/WHEEL")
|
||||
assert dirpath_dist_info.endswith(".dist-info")
|
||||
dirpath_data = dirpath_dist_info.removesuffix("dist-info") + "data"
|
||||
dirpath_data_with_slash = dirpath_data + "/"
|
||||
|
||||
# https://docs.python.org/3/library/sysconfig.html#user-scheme
|
||||
user_scheme_map = {}
|
||||
data_map = {}
|
||||
record_path_map = {}
|
||||
|
||||
# Simply strip the prefix in the case of `purelib` & `platlib`
|
||||
# so the modules are found in the expected directory.
|
||||
#
|
||||
# Note that we could support a "bin" and other directories however
|
||||
# for the purpose of Blender scripts, installing command line programs
|
||||
# for Blender's add-ons to access via `bin` is quite niche (although not impossible).
|
||||
#
|
||||
# For the time being this is *not* full support Python's "User scheme"
|
||||
# just enough to import modules.
|
||||
#
|
||||
# Omitting other directories such as "includes" & "scripts" means these will remain in the
|
||||
# `{module-XXX}.data/includes` sub-directory, support for them can always be added if needed.
|
||||
|
||||
user_scheme_map["purelib"] = ""
|
||||
user_scheme_map["platlib"] = ""
|
||||
|
||||
for member in zip_fh.infolist():
|
||||
filepath_orig = member.filename
|
||||
if not filepath_orig.startswith(dirpath_data_with_slash):
|
||||
continue
|
||||
|
||||
path_base, path_tail = filepath_orig[len(dirpath_data_with_slash):].partition("/")[0::2]
|
||||
# The path may not contain a tail, skip these cases.
|
||||
if not path_tail:
|
||||
continue
|
||||
|
||||
if (path_base_remap := user_scheme_map.get(path_base)) is None:
|
||||
continue
|
||||
|
||||
if path_base_remap:
|
||||
filepath_remap = "{:s}/{:s}".format(path_base_remap, path_tail)
|
||||
else:
|
||||
filepath_remap = path_tail
|
||||
|
||||
member.filename = filepath_remap
|
||||
|
||||
record_path_map[filepath_orig] = filepath_remap
|
||||
|
||||
try:
|
||||
data_map[member_record.filename] = _wheel_record_csv_remap(
|
||||
zip_fh.read(member_record.filename).decode("utf8"),
|
||||
record_path_map,
|
||||
)
|
||||
except Exception as ex:
|
||||
error_fn(ex)
|
||||
return None
|
||||
|
||||
# Nothing to remap.
|
||||
if not record_path_map:
|
||||
return None
|
||||
|
||||
return data_map
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Generic ZIP File Extractions
|
||||
|
||||
def _zipfile_extractall_safe(
|
||||
zip_fh: zipfile.ZipFile,
|
||||
path: str,
|
||||
path_restrict: str,
|
||||
*,
|
||||
error_fn: Callable[[Exception], None],
|
||||
remove_error_fn: Callable[[str, Exception], None],
|
||||
|
||||
# Map zip-file data to bytes.
|
||||
# Only for small files as the mapped data needs to be held in memory.
|
||||
# As it happens for this use case, it's only needed for the CSV file listing.
|
||||
data_map: dict[str, bytes] | None,
|
||||
) -> None:
|
||||
"""
|
||||
A version of ``ZipFile.extractall`` that wont write to paths outside ``path_restrict``.
|
||||
|
||||
Avoids writing this:
|
||||
``zip_fh.extractall(zip_fh, path)``
|
||||
"""
|
||||
sep = os.sep
|
||||
path_restrict = path_restrict.rstrip(sep)
|
||||
if sep == "\\":
|
||||
path_restrict = path_restrict.rstrip("/")
|
||||
path_restrict_with_slash = path_restrict + sep
|
||||
|
||||
# Strip is probably not needed (only if multiple slashes exist).
|
||||
path_prefix = path[len(path_restrict_with_slash):].lstrip(sep)
|
||||
# Switch slashes forward.
|
||||
if sep == "\\":
|
||||
path_prefix = path_prefix.replace("\\", "/").rstrip("/") + "/"
|
||||
else:
|
||||
path_prefix = path_prefix + "/"
|
||||
|
||||
path_restrict_with_slash = path_restrict + sep
|
||||
assert len(path) >= len(path_restrict_with_slash)
|
||||
if not path.startswith(path_restrict_with_slash):
|
||||
# This is an internal error if it ever happens.
|
||||
raise Exception("Expected the restricted directory to start with \"{:s}\"".format(path_restrict_with_slash))
|
||||
|
||||
has_error = False
|
||||
member_index = 0
|
||||
|
||||
# Use an iterator to avoid duplicating the checks (for the cleanup pass).
|
||||
def zip_iter_filtered(*, verbose: bool) -> Iterator[tuple[zipfile.ZipInfo, str, str]]:
|
||||
for member in zip_fh.infolist():
|
||||
filename_orig = member.filename
|
||||
filename_next = path_prefix + filename_orig
|
||||
|
||||
# This isn't likely to happen so accept a noisy print here.
|
||||
# If this ends up happening more often, it could be suppressed.
|
||||
# (although this hints at bigger problems because we might be excluding necessary files).
|
||||
if os.path.normpath(filename_next).startswith(".." + sep):
|
||||
if verbose:
|
||||
print("Skipping path:", filename_next, "that escapes:", path_restrict)
|
||||
continue
|
||||
yield member, filename_orig, filename_next
|
||||
|
||||
for member, filename_orig, filename_next in zip_iter_filtered(verbose=True):
|
||||
# Increment before extracting, so a potential cleanup will a file that failed to extract.
|
||||
member_index += 1
|
||||
|
||||
member.filename = filename_next
|
||||
|
||||
data_transform = None if data_map is None else data_map.get(filename_orig)
|
||||
|
||||
filepath_native = path_restrict + sep + filename_next.replace("/", sep)
|
||||
|
||||
# Extraction can fail for many reasons, see: #132924.
|
||||
try:
|
||||
if data_transform is not None:
|
||||
with open(filepath_native, "wb") as fh:
|
||||
fh.write(data_transform)
|
||||
else:
|
||||
zip_fh.extract(member, path_restrict)
|
||||
except Exception as ex:
|
||||
error_fn(ex)
|
||||
|
||||
print("Failed to extract path:", filepath_native, "error", str(ex))
|
||||
remove_error_fn(filepath_native, ex)
|
||||
has_error = True
|
||||
|
||||
member.filename = filename_orig
|
||||
|
||||
if has_error:
|
||||
break
|
||||
|
||||
# If the zip-file failed to extract, remove all files that were extracted.
|
||||
# This is done so failure to extract a file never results in a partially-working
|
||||
# state which can cause confusing situations for users.
|
||||
if has_error:
|
||||
# NOTE: this currently leaves empty directories which is not ideal.
|
||||
# It's possible to calculate directories created by this extraction but more involved.
|
||||
member_cleanup_len = member_index + 1
|
||||
member_index = 0
|
||||
|
||||
for member, filename_orig, filename_next in zip_iter_filtered(verbose=False):
|
||||
member_index += 1
|
||||
if member_index >= member_cleanup_len:
|
||||
break
|
||||
|
||||
filepath_native = path_restrict + sep + filename_next.replace("/", sep)
|
||||
try:
|
||||
os.unlink(filepath_native)
|
||||
except Exception as ex:
|
||||
remove_error_fn(filepath_native, ex)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wheel Utilities
|
||||
|
||||
|
||||
WHEEL_VERSION_RE = re.compile(r"(\d+)?(?:\.(\d+))?(?:\.(\d+))")
|
||||
|
||||
|
||||
def wheel_version_from_filename_for_cmp(
|
||||
filename: str,
|
||||
) -> tuple[int, int, int, str]:
|
||||
"""
|
||||
Extract the version number for comparison.
|
||||
Note that this only handled the first 3 numbers,
|
||||
the trailing text is compared as a string which is not technically correct
|
||||
however this is not a priority to support since scripts should only be including stable releases,
|
||||
so comparing the first 3 numbers is sufficient. The trailing string is just a tie breaker in the
|
||||
unlikely event it differs.
|
||||
|
||||
If supporting the full spec, comparing: "1.1.dev6" with "1.1.6rc6" for example
|
||||
we could support this doesn't seem especially important as extensions should use major releases.
|
||||
"""
|
||||
filename_split = filename.split("-")
|
||||
if len(filename_split) >= 2:
|
||||
version = filename.split("-")[1]
|
||||
if (version_match := WHEEL_VERSION_RE.match(version)) is not None:
|
||||
groups = version_match.groups()
|
||||
# print(groups)
|
||||
return (
|
||||
int(groups[0]) if groups[0] is not None else 0,
|
||||
int(groups[1]) if groups[1] is not None else 0,
|
||||
int(groups[2]) if groups[2] is not None else 0,
|
||||
version[version_match.end():],
|
||||
)
|
||||
return (0, 0, 0, "")
|
||||
|
||||
|
||||
def wheel_list_deduplicate_as_skip_set(
|
||||
wheel_list: list[WheelSource],
|
||||
) -> set[str]:
|
||||
"""
|
||||
Return all wheel paths to skip.
|
||||
"""
|
||||
wheels_to_skip: set[str] = set()
|
||||
all_wheels: set[str] = {
|
||||
filepath
|
||||
for _, wheels in wheel_list
|
||||
for filepath in wheels
|
||||
}
|
||||
|
||||
# NOTE: this is not optimized.
|
||||
# Probably speed is never an issue here, but this could be sped up.
|
||||
|
||||
# Keep a map from the base name to the "best" wheel,
|
||||
# the other wheels get added to `wheels_to_skip` to be ignored.
|
||||
all_wheels_by_base: dict[str, str] = {}
|
||||
|
||||
for wheel in all_wheels:
|
||||
wheel_filename = os.path.basename(wheel)
|
||||
wheel_base = wheel_filename.partition("-")[0]
|
||||
|
||||
wheel_exists = all_wheels_by_base.get(wheel_base)
|
||||
if wheel_exists is None:
|
||||
all_wheels_by_base[wheel_base] = wheel
|
||||
continue
|
||||
|
||||
wheel_exists_filename = os.path.basename(wheel_exists)
|
||||
if wheel_exists_filename == wheel_filename:
|
||||
# Should never happen because they are converted into a set before looping.
|
||||
assert wheel_exists != wheel
|
||||
# The same wheel is used in two different locations, use a tie breaker for predictability
|
||||
# although the result should be the same.
|
||||
if wheel_exists_filename < wheel_filename:
|
||||
all_wheels_by_base[wheel_base] = wheel
|
||||
wheels_to_skip.add(wheel_exists)
|
||||
else:
|
||||
wheels_to_skip.add(wheel)
|
||||
else:
|
||||
wheel_version = wheel_version_from_filename_for_cmp(wheel_filename)
|
||||
wheel_exists_version = wheel_version_from_filename_for_cmp(wheel_exists_filename)
|
||||
if (
|
||||
(wheel_exists_version < wheel_version) or
|
||||
# Tie breaker for predictability.
|
||||
((wheel_exists_version == wheel_version) and (wheel_exists_filename < wheel_filename))
|
||||
):
|
||||
all_wheels_by_base[wheel_base] = wheel
|
||||
wheels_to_skip.add(wheel_exists)
|
||||
else:
|
||||
wheels_to_skip.add(wheel)
|
||||
|
||||
return wheels_to_skip
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Public Function to Apply Wheels
|
||||
|
||||
def apply_action(
|
||||
*,
|
||||
local_dir: str,
|
||||
local_dir_site_packages: str,
|
||||
wheel_list: list[WheelSource],
|
||||
error_fn: Callable[[Exception], None],
|
||||
remove_error_fn: Callable[[str, Exception], None],
|
||||
debug: bool,
|
||||
) -> None:
|
||||
"""
|
||||
:param local_dir:
|
||||
The location wheels are stored.
|
||||
Typically: ``~/.config/blender/4.2/extensions/.local``.
|
||||
|
||||
WARNING: files under this directory may be removed.
|
||||
:param local_dir_site_packages:
|
||||
The path which wheels are extracted into.
|
||||
Typically: ``~/.config/blender/4.2/extensions/.local/lib/python3.11/site-packages``.
|
||||
"""
|
||||
|
||||
# NOTE: we could avoid scanning the wheel directories however:
|
||||
# Recursively removing all paths on the users system can be considered relatively risky
|
||||
# even if this is located in a known location under the users home directory - better avoid.
|
||||
# So build a list of wheel paths and only remove the unused paths from this list.
|
||||
wheels_installed, _paths_unknown = _wheels_from_dir(local_dir_site_packages)
|
||||
|
||||
# Wheels and their top level directories (which would be installed).
|
||||
wheels_packages: dict[str, list[str]] = {}
|
||||
|
||||
# Map the wheel ID to path.
|
||||
wheels_dir_info_to_filepath_map: dict[str, str] = {}
|
||||
|
||||
# NOTE(@ideasman42): the wheels skip-set only de-duplicates at the level of the base-name of the wheels filename.
|
||||
# So the wheel file-paths:
|
||||
# - `pip-24.0-py3-none-any.whl`
|
||||
# - `pip-22.1-py2-none-any.whl`
|
||||
# Will both extract the *base* name `pip`, de-duplicating by skipping the wheels with an older version number.
|
||||
# This is not fool-proof, because it is possible files inside the `.whl` conflict upon extraction.
|
||||
# In practice I consider this fairly unlikely because:
|
||||
# - Practically all wheels extract to their top-level module names.
|
||||
# - Modules are mainly downloaded from the Python package index.
|
||||
#
|
||||
# Having two modules conflict is possible but this is an issue outside of Blender,
|
||||
# as it's most likely quite rare and generally avoided with unique module names,
|
||||
# this is not considered a problem to "solve" at the moment.
|
||||
#
|
||||
# The one exception to this assumption is any extensions that bundle `.whl` files that aren't
|
||||
# available on the Python package index. In this case naming collisions are more likely.
|
||||
# This probably needs to be handled on a policy level - if the `.whl` author also maintains
|
||||
# the extension they can in all likelihood make the module a sub-module of the extension
|
||||
# without the need to use `.whl` files.
|
||||
wheels_to_skip = wheel_list_deduplicate_as_skip_set(wheel_list)
|
||||
|
||||
for _key, wheels in wheel_list:
|
||||
for wheel in wheels:
|
||||
if wheel in wheels_to_skip:
|
||||
continue
|
||||
if (wheel_info := _wheel_info_dir_from_zip(wheel)) is None:
|
||||
continue
|
||||
dir_info, toplevel_paths_list = wheel_info
|
||||
wheels_packages[dir_info] = toplevel_paths_list
|
||||
|
||||
wheels_dir_info_to_filepath_map[dir_info] = wheel
|
||||
|
||||
# Now there is two sets of packages, the ones we need and the ones we have.
|
||||
|
||||
# -----
|
||||
# Clear
|
||||
|
||||
# First remove installed packages no longer needed:
|
||||
for dir_info, toplevel_paths_list in wheels_installed.items():
|
||||
if dir_info in wheels_packages:
|
||||
continue
|
||||
|
||||
# Remove installed packages which aren't needed any longer.
|
||||
for filepath_rel in (dir_info, *toplevel_paths_list):
|
||||
filepath_abs = os.path.join(local_dir_site_packages, filepath_rel)
|
||||
if not os.path.exists(filepath_abs):
|
||||
continue
|
||||
|
||||
if debug:
|
||||
print("removing wheel:", filepath_rel)
|
||||
|
||||
ex: Exception | None = None
|
||||
if os.path.isdir(filepath_abs):
|
||||
ex = _rmtree_safe(filepath_abs, local_dir)
|
||||
# For symbolic-links, use remove as a fallback.
|
||||
if ex is not None:
|
||||
if _remove_safe(filepath_abs) is None:
|
||||
ex = None
|
||||
else:
|
||||
ex = _remove_safe(filepath_abs)
|
||||
|
||||
if ex:
|
||||
if debug:
|
||||
print("failed to remove:", filepath_rel, str(ex), "setting stale")
|
||||
|
||||
# If the directory (or file) can't be removed, make it stale and try to remove it later.
|
||||
remove_error_fn(filepath_abs, ex)
|
||||
|
||||
# -----
|
||||
# Setup
|
||||
|
||||
# Install packages that need to be installed:
|
||||
for dir_info, toplevel_paths_list in wheels_packages.items():
|
||||
if dir_info in wheels_installed:
|
||||
continue
|
||||
|
||||
if debug:
|
||||
for filepath_rel in toplevel_paths_list:
|
||||
print("adding wheel:", filepath_rel)
|
||||
filepath = wheels_dir_info_to_filepath_map[dir_info]
|
||||
# `ZipFile.extractall` is needed because some wheels contain paths that point to parent directories.
|
||||
# Handle this *safely* by allowing extracting to parent directories but limit this to the `local_dir`.
|
||||
|
||||
try:
|
||||
# pylint: disable-next=consider-using-with
|
||||
zip_fh_context = zipfile.ZipFile(filepath, mode="r")
|
||||
except Exception as ex:
|
||||
print("Error ({:s}) opening zip-file: {:s}".format(str(ex), filepath))
|
||||
error_fn(ex)
|
||||
continue
|
||||
|
||||
with contextlib.closing(zip_fh_context) as zip_fh:
|
||||
|
||||
# Support non `Root-is-purelib` wheels, where the data needs to be remapped, see: .
|
||||
# Typically `data_map` will be none, see: #132843 for the use case that requires this functionality.
|
||||
#
|
||||
# NOTE: these wheels should be included in tests (generated and checked to properly install).
|
||||
# Unfortunately there doesn't seem to a be practical way to generate them using the `wheel` module.
|
||||
data_map = _wheel_zipfile_normalize(
|
||||
zip_fh,
|
||||
error_fn=error_fn,
|
||||
)
|
||||
|
||||
_zipfile_extractall_safe(
|
||||
zip_fh,
|
||||
local_dir_site_packages,
|
||||
local_dir,
|
||||
error_fn=error_fn,
|
||||
remove_error_fn=remove_error_fn,
|
||||
data_map=data_map,
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import io
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
__all__ = (
|
||||
'mutex_lock_and_open',
|
||||
'mutex_lock_and_open_with_retry',
|
||||
'MutexAcquisitionError',
|
||||
)
|
||||
|
||||
|
||||
class MutexAcquisitionError(Exception):
|
||||
"""Raised when `mutex_lock_and_open_with_retry()` cannot obtain a lock."""
|
||||
pass
|
||||
|
||||
|
||||
def mutex_lock_and_open_with_retry(file_path: Path,
|
||||
mode: str,
|
||||
*,
|
||||
max_tries: int,
|
||||
wait_time_sec: float) -> tuple[io.IOBase, Callable[[io.IOBase], None]]:
|
||||
"""Obtain an exclusive lock on a file, retrying when that fails.
|
||||
|
||||
See `mutex_lock_and_open()` for the lock semantics, and the first two parameters.
|
||||
|
||||
:param max_tries: number of times the code attempts to acquire the lock.
|
||||
:param wait_time: amount of time (in seconds) to wait between tries.
|
||||
|
||||
:returns: A tuple (file, unlocker) is returned. The caller should call
|
||||
`unlocker(file)` to unlock the mutex.
|
||||
|
||||
:raises MutexAcquisitionError: when the lock cannot be acquired within the
|
||||
given number of tries.
|
||||
"""
|
||||
|
||||
if 'r' in mode and not file_path.exists():
|
||||
# Opening a non-existent file for read is not going to work. The retry
|
||||
# logic is meant for the locking, and not to wait for the file's
|
||||
# existence.
|
||||
raise FileNotFoundError(file_path)
|
||||
|
||||
for _ in range(max_tries):
|
||||
meta_file, unlocker = mutex_lock_and_open(file_path, mode)
|
||||
if meta_file is not None:
|
||||
assert unlocker is not None
|
||||
return meta_file, unlocker
|
||||
time.sleep(wait_time_sec)
|
||||
|
||||
raise MutexAcquisitionError("could not open & lock file {!s}".format(file_path))
|
||||
|
||||
|
||||
def mutex_lock_and_open(file_path: Path, mode: str) -> tuple[io.IOBase | None, Callable[[io.IOBase], None] | None]:
|
||||
"""Obtain an exclusive lock on a file.
|
||||
|
||||
Create a file on disk, and immediately lock it for exclusive use by this
|
||||
process.
|
||||
|
||||
This uses approaches from:
|
||||
- https://www.pythontutorials.net/blog/make-sure-only-a-single-instance-of-a-program-is-running/
|
||||
- https://yakking.branchable.com/posts/procrun-2-pidfiles/
|
||||
|
||||
:param: mode MUST be a binary mode, to be compatible with the file locking
|
||||
on Windows. So either 'rb' or 'wb'.
|
||||
|
||||
:returns: If the file was opened & locked successfully, a tuple (file,
|
||||
unlocker) is returned. Otherwise returns None. The caller should call
|
||||
`unlocker(file)` to unlock the mutex.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
# Choose platform-dependent _obtain_lock(file) and _release_lock() functions.
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
def _obtain_lock(file: io.IOBase) -> None:
|
||||
# Lock the first byte of the file. This is an arbitrary choice, but
|
||||
# MUST be mirrored in the unlock function below as well.
|
||||
file.seek(0)
|
||||
msvcrt.locking(file.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
|
||||
def _unlock_and_close(file: io.IOBase) -> None:
|
||||
# Ensure the same byte is unlocked as was locked in the function above.
|
||||
file.seek(0)
|
||||
msvcrt.locking(file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
file.close()
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
def _obtain_lock(file: io.IOBase) -> None:
|
||||
fcntl.flock(file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
def _unlock_and_close(file: io.IOBase) -> None:
|
||||
# Closing the file automatically releases the lock.
|
||||
file.close()
|
||||
|
||||
assert isinstance(file_path, Path)
|
||||
assert 'b' in mode, "mode must include 'b' for binary"
|
||||
|
||||
# It is not suitable here to use an 'exclusive create' ('x' option) here.
|
||||
# That will still create a race condition, with the space between creation
|
||||
# of the file and locking it. So, better to make the existence of the file
|
||||
# meaningless, and only communicate the lock state with an actual file-system
|
||||
# lock.
|
||||
try:
|
||||
# Type is ignored here, because the type checker doesn't realize that
|
||||
# the above assert ensures the file is opened in a binary mode.
|
||||
lockfile: io.IOBase
|
||||
lockfile = file_path.open(mode) # type: ignore
|
||||
except OSError:
|
||||
# On Windows, opening a file for writing, while another process already
|
||||
# has it open, can fail. That just means somebody else has ownership of
|
||||
# it.
|
||||
return None, None
|
||||
|
||||
try:
|
||||
_obtain_lock(lockfile)
|
||||
except OSError:
|
||||
# Lock is already held by another Blender.
|
||||
lockfile.close()
|
||||
return None, None
|
||||
|
||||
# We have obtained an exclusive lock, which the OS will release when this
|
||||
# process is killed.
|
||||
return lockfile, _unlock_and_close
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
@@ -0,0 +1,408 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class BezierHandle(Enum):
|
||||
LEFT = 1
|
||||
RIGHT = 2
|
||||
|
||||
|
||||
class AttributeGetterSetter:
|
||||
"""
|
||||
Helper class to get and set attributes at an index for a domain.
|
||||
"""
|
||||
__slots__ = ("_attributes", "_index", "_domain")
|
||||
|
||||
def __init__(self, attributes, index, domain):
|
||||
self._attributes = attributes
|
||||
self._index = index
|
||||
self._domain = domain
|
||||
|
||||
def _get_attribute(self, name, type, default):
|
||||
if attribute := self._attributes.get(name):
|
||||
if type in {'FLOAT', 'INT', 'STRING', 'BOOLEAN', 'INT8', 'INT32_2D', 'QUATERNION', 'FLOAT4X4'}:
|
||||
return attribute.data[self._index].value
|
||||
elif type == 'FLOAT_VECTOR':
|
||||
return attribute.data[self._index].vector
|
||||
elif type in {'FLOAT_COLOR', 'BYTE_COLOR'}:
|
||||
return attribute.data[self._index].color
|
||||
else:
|
||||
raise Exception("Unknown type {!r}".format(type))
|
||||
return default
|
||||
|
||||
def _set_attribute_value(self, attribute, index, type, value):
|
||||
if type in {'FLOAT', 'INT', 'STRING', 'BOOLEAN', 'INT8', 'INT32_2D', 'QUATERNION', 'FLOAT4X4'}:
|
||||
attribute.data[index].value = value
|
||||
elif type == 'FLOAT_VECTOR':
|
||||
attribute.data[index].vector = value
|
||||
elif type in {'FLOAT_COLOR', 'BYTE_COLOR'}:
|
||||
attribute.data[index].color = value
|
||||
else:
|
||||
raise Exception("Unknown type {!r}".format(type))
|
||||
|
||||
def _set_attribute(self, name, type, value, default):
|
||||
if attribute := self._attributes.get(name):
|
||||
self._set_attribute_value(attribute, self._index, type, value)
|
||||
elif attribute := self._attributes.new(name, type, self._domain):
|
||||
# Fill attribute with default value
|
||||
num = self._attributes.domain_size(self._domain)
|
||||
for i in range(num):
|
||||
self._set_attribute_value(attribute, i, type, default)
|
||||
self._set_attribute_value(attribute, self._index, type, value)
|
||||
else:
|
||||
raise Exception(
|
||||
"Could not create attribute {:s} of type {!r}".format(name, type))
|
||||
|
||||
|
||||
class SliceHelper:
|
||||
"""
|
||||
Helper class to handle custom slicing.
|
||||
"""
|
||||
__slots__ = ("_start", "_stop", "_size")
|
||||
|
||||
def __init__(self, start: int, stop: int):
|
||||
self._start = start
|
||||
self._stop = stop
|
||||
self._size = stop - start
|
||||
|
||||
def __len__(self):
|
||||
return self._size
|
||||
|
||||
def _is_valid_index(self, key: int):
|
||||
if self._size <= 0:
|
||||
return False
|
||||
if key < 0:
|
||||
# Support indexing from the end.
|
||||
return abs(key) <= self._size
|
||||
return abs(key) < self._size
|
||||
|
||||
def _getitem_helper(self, key):
|
||||
if isinstance(key, int):
|
||||
if not self._is_valid_index(key):
|
||||
raise IndexError("Key {:d} is out of range".format(key))
|
||||
# Turn the key into an index.
|
||||
return self._start + (key % self._size)
|
||||
elif isinstance(key, slice):
|
||||
if key.step is not None and key.step != 1:
|
||||
raise ValueError("Step values != 1 not supported")
|
||||
# Default to 0 and size for the start and stop values.
|
||||
start = key.start if key.start is not None else 0
|
||||
stop = key.stop if key.stop is not None else self._size
|
||||
# Wrap negative indices.
|
||||
start = self._size + start if start < 0 else start
|
||||
stop = self._size + stop if stop < 0 else stop
|
||||
# Clamp start and stop.
|
||||
start = max(0, min(start, self._size))
|
||||
stop = max(0, min(stop, self._size))
|
||||
return (self._start + start, self._start + stop)
|
||||
else:
|
||||
raise TypeError("Unexpected index of type {!r}".format(type(key)))
|
||||
|
||||
|
||||
def def_prop_for_attribute(attr_name, type, default, doc):
|
||||
"""
|
||||
Creates a property that can read and write an attribute.
|
||||
"""
|
||||
|
||||
def fget(self):
|
||||
# Define `getter` callback for property.
|
||||
return self._get_attribute(attr_name, type, default)
|
||||
|
||||
def fset(self, value):
|
||||
# Define `setter` callback for property.
|
||||
self._set_attribute(attr_name, type, value, default)
|
||||
|
||||
prop = property(fget=fget, fset=fset, doc=doc)
|
||||
return prop
|
||||
|
||||
|
||||
def DefAttributeGetterSetters(attributes_list):
|
||||
"""
|
||||
A class decorator that reads a list of attribute information &
|
||||
creates properties on the class with ``getters`` & ``setters``.
|
||||
"""
|
||||
def wrapper(cls):
|
||||
for prop_name, attr_name, type, default, doc in attributes_list:
|
||||
prop = def_prop_for_attribute(attr_name, type, default, doc)
|
||||
setattr(cls, prop_name, prop)
|
||||
return cls
|
||||
return wrapper
|
||||
|
||||
|
||||
class GreasePencilStrokePointHandle:
|
||||
"""Proxy giving read-only/write access to Bézier handle data."""
|
||||
|
||||
__slots__ = ("_point", "_handle")
|
||||
|
||||
def __init__(self, point, handle: BezierHandle):
|
||||
self._point = point
|
||||
self._handle = handle
|
||||
|
||||
@property
|
||||
def position(self):
|
||||
attribute_name = f"handle_{self._handle.name.lower()}"
|
||||
return self._point._get_attribute(attribute_name, "FLOAT_VECTOR", (0.0, 0.0, 0.0))
|
||||
|
||||
@position.setter
|
||||
def position(self, value):
|
||||
attribute_name = f"handle_{self._handle.name.lower()}"
|
||||
self._point._set_attribute(attribute_name, "FLOAT_VECTOR", value, (0.0, 0.0, 0.0))
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
attribute_name = f"handle_type_{self._handle.name.lower()}"
|
||||
return self._point._get_attribute(attribute_name, "INT", 0)
|
||||
|
||||
# Note: Setting the handle type is not allowed because recomputing the handle types isn't exposed to Python yet.
|
||||
|
||||
@property
|
||||
def select(self):
|
||||
attribute_name = f".selection_handle_{self._handle.name.lower()}"
|
||||
return self._point._get_attribute(attribute_name, "BOOLEAN", True)
|
||||
|
||||
@select.setter
|
||||
def select(self, value):
|
||||
attribute_name = f".selection_handle_{self._handle.name.lower()}"
|
||||
self._point._set_attribute(attribute_name, 'BOOLEAN', value, True)
|
||||
|
||||
# Define the list of attributes that should be exposed as read/write properties on the class.
|
||||
|
||||
|
||||
@DefAttributeGetterSetters([
|
||||
# Property Name, Attribute Name, Type, Default Value, Docstring.
|
||||
("radius", "radius", 'FLOAT', 0.01, "The radius of the point."),
|
||||
("opacity", "opacity", 'FLOAT', 1.0, "The opacity of the point."),
|
||||
("vertex_color", "vertex_color", 'FLOAT_COLOR', (0.0, 0.0, 0.0, 0.0),
|
||||
"The color for this point. The alpha value is used as a mix factor with the base color of the stroke."),
|
||||
("rotation", "rotation", 'FLOAT', 0.0,
|
||||
"The rotation for this point. Used to rotate textures."),
|
||||
("delta_time", "delta_time", 'FLOAT', 0.0,
|
||||
"The time delta in seconds since the start of the stroke."),
|
||||
])
|
||||
class GreasePencilStrokePoint(AttributeGetterSetter):
|
||||
"""
|
||||
A helper class to get access to stroke point data.
|
||||
"""
|
||||
__slots__ = ("_drawing", "_curve_index", "_point_index")
|
||||
|
||||
def __init__(self, drawing, curve_index, point_index):
|
||||
super().__init__(drawing.attributes, point_index, 'POINT')
|
||||
self._drawing = drawing
|
||||
self._curve_index = curve_index
|
||||
self._point_index = point_index
|
||||
|
||||
@property
|
||||
def position(self):
|
||||
"""
|
||||
The position of the point (in local space).
|
||||
"""
|
||||
if attribute := self._attributes.get("position"):
|
||||
return attribute.data[self._point_index].vector
|
||||
# Position attribute should always exist, but return default just in case.
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
@position.setter
|
||||
def position(self, value):
|
||||
# Position attribute should always exist
|
||||
if attribute := self._attributes.get("position"):
|
||||
attribute.data[self._point_index].vector = value
|
||||
# Tag the positions of the drawing.
|
||||
self._drawing.tag_positions_changed()
|
||||
|
||||
@property
|
||||
def select(self):
|
||||
"""
|
||||
The selection state for this point.
|
||||
"""
|
||||
if attribute := self._attributes.get(".selection"):
|
||||
if attribute.domain == 'CURVE':
|
||||
return attribute.data[self._curve_index].value
|
||||
elif attribute.domain == 'POINT':
|
||||
return attribute.data[self._point_index].value
|
||||
# If the attribute doesn't exist, everything is selected.
|
||||
return True
|
||||
|
||||
@select.setter
|
||||
def select(self, value):
|
||||
if attribute := self._attributes.get(".selection"):
|
||||
if attribute.domain == 'CURVE':
|
||||
attribute.data[self._curve_index].value = value
|
||||
elif attribute.domain == 'POINT':
|
||||
attribute.data[self._point_index].value = value
|
||||
elif attribute := self._attributes.new(".selection", 'BOOLEAN', 'POINT'):
|
||||
attribute.data[self._point_index].value = value
|
||||
|
||||
@property
|
||||
def handle_left(self):
|
||||
"""
|
||||
Return the left Bézier handle proxy, or None if this point's stroke isn't Bézier.
|
||||
"""
|
||||
stroke_curve_type = self._drawing.strokes[self._curve_index].curve_type
|
||||
if stroke_curve_type == 2: # 2 == Bézier (enum value in Blender)
|
||||
return GreasePencilStrokePointHandle(self, BezierHandle.LEFT)
|
||||
return None
|
||||
|
||||
@property
|
||||
def handle_right(self):
|
||||
"""
|
||||
Return the right Bézier handle proxy, or None if this point's stroke isn't Bézier.
|
||||
"""
|
||||
stroke_curve_type = self._drawing.strokes[self._curve_index].curve_type
|
||||
if stroke_curve_type == 2:
|
||||
return GreasePencilStrokePointHandle(self, BezierHandle.RIGHT)
|
||||
return None
|
||||
|
||||
|
||||
class GreasePencilStrokePointSlice(SliceHelper):
|
||||
"""
|
||||
A helper class that represents a slice of GreasePencilStrokePoint's.
|
||||
"""
|
||||
__slots__ = ("_drawing", "_curve_index")
|
||||
|
||||
def __init__(self, drawing, curve_index: int, start: int, stop: int):
|
||||
super().__init__(start, stop)
|
||||
self._drawing = drawing
|
||||
self._curve_index = curve_index
|
||||
|
||||
def __len__(self):
|
||||
return super().__len__()
|
||||
|
||||
def __getitem__(self, key):
|
||||
key = super()._getitem_helper(key)
|
||||
if isinstance(key, int):
|
||||
return GreasePencilStrokePoint(self._drawing, self._curve_index, key)
|
||||
elif isinstance(key, tuple):
|
||||
start, stop = key
|
||||
return GreasePencilStrokePointSlice(self._drawing, self._curve_index, start, stop)
|
||||
|
||||
|
||||
# Define the list of attributes that should be exposed as read/write properties on the class.
|
||||
@DefAttributeGetterSetters([
|
||||
# Property Name, Attribute Name, Type, Default Value, Docstring.
|
||||
("cyclic", "cyclic", 'BOOLEAN', False, "The closed state for this stroke."),
|
||||
("material_index", "material_index", 'INT', 0,
|
||||
"The index of the material for this stroke."),
|
||||
("fill_id", "fill_id", 'INT', 0, "The fill id of this stroke."),
|
||||
("hide_stroke", "hide_stroke", 'BOOLEAN', False, "The stroke visibility state."),
|
||||
("softness", "softness", 'FLOAT', 0.0,
|
||||
"Used by the renderer to generate a soft gradient from the stroke center line to the edges."),
|
||||
("start_cap", "start_cap", 'INT8', 0, "The type of start cap of this stroke."),
|
||||
("end_cap", "end_cap", 'INT8', 0, "The type of end cap of this stroke."),
|
||||
("aspect_ratio", "aspect_ratio", 'FLOAT', 1.0,
|
||||
"The aspect ratio (x/y) used for textures. "),
|
||||
("fill_opacity", "fill_opacity", 'FLOAT', 1.0, "The opacity of the fill."),
|
||||
("fill_color", "fill_color", 'FLOAT_COLOR',
|
||||
(0.0, 0.0, 0.0, 0.0), "The color of the fill."),
|
||||
("time_start", "init_time", 'FLOAT', 0.0,
|
||||
"A time value for when the stroke was created."),
|
||||
])
|
||||
class GreasePencilStroke(AttributeGetterSetter):
|
||||
"""
|
||||
A helper class to get access to stroke data.
|
||||
"""
|
||||
__slots__ = ("_drawing", "_curve_index", "_points_start_index", "_points_end_index")
|
||||
|
||||
def __init__(self, drawing, curve_index: int, points_start_index: int, points_end_index: int):
|
||||
super().__init__(drawing.attributes, curve_index, 'CURVE')
|
||||
self._drawing = drawing
|
||||
self._curve_index = curve_index
|
||||
self._points_start_index = points_start_index
|
||||
self._points_end_index = points_end_index
|
||||
|
||||
@property
|
||||
def points(self):
|
||||
"""
|
||||
Return a slice of points in the stroke.
|
||||
"""
|
||||
return GreasePencilStrokePointSlice(
|
||||
self._drawing,
|
||||
self._curve_index,
|
||||
self._points_start_index,
|
||||
self._points_end_index)
|
||||
|
||||
def add_points(self, count: int):
|
||||
"""
|
||||
Add new points at the end of the stroke and returns the new points as a list.
|
||||
"""
|
||||
previous_end = self._points_end_index
|
||||
new_size = self._points_end_index - self._points_start_index + count
|
||||
self._drawing.resize_strokes(
|
||||
sizes=[new_size],
|
||||
indices=[self._curve_index],
|
||||
)
|
||||
self._points_end_index = self._points_start_index + new_size
|
||||
return GreasePencilStrokePointSlice(self._drawing, self._curve_index, previous_end, self._points_end_index)
|
||||
|
||||
def remove_points(self, count: int):
|
||||
"""
|
||||
Remove points at the end of the stroke.
|
||||
"""
|
||||
new_size = self._points_end_index - self._points_start_index - count
|
||||
# A stroke need to have at least one point.
|
||||
if new_size < 1:
|
||||
new_size = 1
|
||||
self._drawing.resize_strokes(
|
||||
sizes=[new_size],
|
||||
indices=[self._curve_index],
|
||||
)
|
||||
self._points_end_index = self._points_start_index + new_size
|
||||
|
||||
@property
|
||||
def curve_type(self):
|
||||
"""
|
||||
The curve type of this stroke.
|
||||
"""
|
||||
# Note: This is read-only which is why it is not part of the AttributeGetterSetters.
|
||||
return super()._get_attribute("curve_type", 'INT8', 0)
|
||||
|
||||
@property
|
||||
def select(self):
|
||||
"""
|
||||
The selection state for this stroke.
|
||||
"""
|
||||
if attribute := self._attributes.get(".selection"):
|
||||
if attribute.domain == 'CURVE':
|
||||
return attribute.data[self._curve_index].value
|
||||
elif attribute.domain == 'POINT':
|
||||
return any([attribute.data[point_index].value for point_index in range(
|
||||
self._points_start_index, self._points_end_index)])
|
||||
# If the attribute doesn't exist, everything is selected.
|
||||
return True
|
||||
|
||||
@select.setter
|
||||
def select(self, value):
|
||||
if attribute := self._attributes.get(".selection"):
|
||||
if attribute.domain == 'CURVE':
|
||||
attribute.data[self._curve_index].value = value
|
||||
elif attribute.domain == 'POINT':
|
||||
for point_index in range(self._points_start_index, self._points_end_index):
|
||||
attribute.data[point_index].value = value
|
||||
elif attribute := self._attributes.new(".selection", 'BOOLEAN', 'CURVE'):
|
||||
attribute.data[self._curve_index].value = value
|
||||
|
||||
|
||||
class GreasePencilStrokeSlice(SliceHelper):
|
||||
"""
|
||||
A helper class that represents a slice of GreasePencilStroke's.
|
||||
"""
|
||||
__slots__ = ("_drawing", "_curve_offsets")
|
||||
|
||||
def __init__(self, drawing, start: int, stop: int):
|
||||
super().__init__(start, stop)
|
||||
self._drawing = drawing
|
||||
self._curve_offsets = drawing.curve_offsets
|
||||
|
||||
def __len__(self):
|
||||
return super().__len__()
|
||||
|
||||
def __getitem__(self, key):
|
||||
key = super()._getitem_helper(key)
|
||||
if isinstance(key, int):
|
||||
offsets = self._curve_offsets
|
||||
return GreasePencilStroke(self._drawing, key, offsets[key].value, offsets[key + 1].value)
|
||||
elif isinstance(key, tuple):
|
||||
start, stop = key
|
||||
return GreasePencilStrokeSlice(self._drawing, start, stop)
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
1683
blender-5.2.0/scripts/modules/_bpy_internal/http/downloader.py
Normal file
1683
blender-5.2.0/scripts/modules/_bpy_internal/http/downloader.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
@@ -0,0 +1,598 @@
|
||||
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# TODO: file-type icons are currently not setup.
|
||||
# Currently `xdg-icon-resource` doesn't support SVG's, so we would need to generate PNG's.
|
||||
# Or wait until SVG's are supported, see: https://gitlab.freedesktop.org/xdg/xdg-utils/-/merge_requests/41
|
||||
#
|
||||
# NOTE: Typically this will run from Blender, you may also run this directly from Python
|
||||
# which can be useful for testing.
|
||||
|
||||
__all__ = (
|
||||
"register",
|
||||
"unregister",
|
||||
)
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from collections.abc import (
|
||||
Callable,
|
||||
)
|
||||
|
||||
VERBOSE = True
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Environment
|
||||
|
||||
HOME_DIR = os.path.normpath(os.path.expanduser("~"))
|
||||
|
||||
# https://wiki.archlinux.org/title/XDG_Base_Directory
|
||||
# Typically: `~/.local/share`.
|
||||
XDG_DATA_HOME = os.environ.get("XDG_DATA_HOME") or os.path.join(HOME_DIR, ".local", "share")
|
||||
|
||||
HOMEDIR_LOCAL_BIN = os.path.join(HOME_DIR, ".local", "bin")
|
||||
|
||||
BLENDER_ENV = "bpy" in sys.modules
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Programs
|
||||
|
||||
# The command `xdg-mime` handles most of the file association actions.
|
||||
XDG_MIME_PROG = shutil.which("xdg-mime") or ""
|
||||
|
||||
# Initialize by `bpy` or command line arguments.
|
||||
BLENDER_BIN = ""
|
||||
# Set to `os.path.dirname(BLENDER_BIN)`.
|
||||
BLENDER_DIR = ""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Path Constants
|
||||
|
||||
# These files are included along side a portable Blender installation.
|
||||
BLENDER_DESKTOP = "blender.desktop"
|
||||
# The target binary.
|
||||
BLENDER_FILENAME = "blender"
|
||||
# The target binary (thumbnailer).
|
||||
BLENDER_THUMBNAILER_FILENAME = "blender-thumbnailer"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Other Constants
|
||||
|
||||
# The mime type Blender users.
|
||||
BLENDER_MIME = "application/x-blender"
|
||||
# Use `/usr/local` because this is not managed by the systems package manager.
|
||||
SYSTEM_PREFIX = "/usr/local"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utility Functions
|
||||
|
||||
|
||||
# Display a short path, for nicer display only.
|
||||
def filepath_repr(filepath: str) -> str:
|
||||
if filepath.startswith(HOME_DIR):
|
||||
return "~" + filepath[len(HOME_DIR):]
|
||||
return filepath
|
||||
|
||||
|
||||
def system_path_contains(dirpath: str) -> bool:
|
||||
dirpath = os.path.normpath(dirpath)
|
||||
for path in os.environ.get("PATH", "").split(os.pathsep):
|
||||
# `$PATH` can include relative locations.
|
||||
path = os.path.normpath(os.path.abspath(path))
|
||||
if path == dirpath:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def filepath_ensure_removed(path: str) -> bool:
|
||||
# When removing files to make way for newly copied file an `os.path.exists`
|
||||
# check isn't sufficient as the path may be a broken symbolic-link.
|
||||
if os.path.lexists(path):
|
||||
os.remove(path)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Handle Associations
|
||||
#
|
||||
# On registration when handlers return False this causes registration to fail and unregister to be called.
|
||||
# Non fatal errors should print a message and return True instead.
|
||||
|
||||
def handle_bin(do_register: bool, all_users: bool) -> str | None:
|
||||
if all_users:
|
||||
dirpath_dst = os.path.join(SYSTEM_PREFIX, "bin")
|
||||
else:
|
||||
dirpath_dst = HOMEDIR_LOCAL_BIN
|
||||
|
||||
if VERBOSE:
|
||||
sys.stdout.write("- {:s} symbolic-links in: {:s}\n".format(
|
||||
("Setup" if do_register else "Remove"),
|
||||
filepath_repr(dirpath_dst),
|
||||
))
|
||||
|
||||
if do_register:
|
||||
if not all_users:
|
||||
if not system_path_contains(dirpath_dst):
|
||||
sys.stdout.write(
|
||||
"The PATH environment variable doesn't contain \"{:s}\", not creating symbolic-links\n".format(
|
||||
dirpath_dst,
|
||||
))
|
||||
# NOTE: this is not an error, don't consider it a failure.
|
||||
return None
|
||||
|
||||
os.makedirs(dirpath_dst, exist_ok=True)
|
||||
|
||||
# Full path, then name to create at the destination.
|
||||
files_to_link = [
|
||||
(BLENDER_BIN, BLENDER_FILENAME, False),
|
||||
]
|
||||
|
||||
blender_thumbnailer_src = os.path.join(BLENDER_DIR, BLENDER_THUMBNAILER_FILENAME)
|
||||
if os.path.exists(blender_thumbnailer_src):
|
||||
# Unfortunately the thumbnailer must be copied for `bwrap` to find it.
|
||||
files_to_link.append((blender_thumbnailer_src, BLENDER_THUMBNAILER_FILENAME, True))
|
||||
else:
|
||||
sys.stdout.write(" Thumbnailer not found, skipping: \"{:s}\"\n".format(blender_thumbnailer_src))
|
||||
|
||||
for filepath_src, filename, do_full_copy in files_to_link:
|
||||
filepath_dst = os.path.join(dirpath_dst, filename)
|
||||
filepath_ensure_removed(filepath_dst)
|
||||
if not do_register:
|
||||
continue
|
||||
|
||||
if not os.path.exists(filepath_src):
|
||||
sys.stderr.write("File not found, skipping link: \"{:s}\" -> \"{:s}\"\n".format(
|
||||
filepath_src, filepath_dst,
|
||||
))
|
||||
if do_full_copy:
|
||||
shutil.copyfile(filepath_src, filepath_dst)
|
||||
os.chmod(filepath_dst, 0o755)
|
||||
else:
|
||||
os.symlink(filepath_src, filepath_dst)
|
||||
return None
|
||||
|
||||
|
||||
def handle_desktop_file(do_register: bool, all_users: bool) -> str | None:
|
||||
# `cp ./blender.desktop ~/.local/share/applications/`
|
||||
|
||||
filename = BLENDER_DESKTOP
|
||||
|
||||
if all_users:
|
||||
base_dir = os.path.join(SYSTEM_PREFIX, "share")
|
||||
else:
|
||||
base_dir = XDG_DATA_HOME
|
||||
|
||||
dirpath_dst = os.path.join(base_dir, "applications")
|
||||
|
||||
filepath_desktop_src = os.path.join(BLENDER_DIR, filename)
|
||||
filepath_desktop_dst = os.path.join(dirpath_dst, filename)
|
||||
|
||||
if VERBOSE:
|
||||
sys.stdout.write("- {:s} desktop-file: {:s}\n".format(
|
||||
("Setup" if do_register else "Remove"),
|
||||
filepath_repr(filepath_desktop_dst),
|
||||
))
|
||||
|
||||
filepath_ensure_removed(filepath_desktop_dst)
|
||||
if not do_register:
|
||||
return None
|
||||
|
||||
if not os.path.exists(filepath_desktop_src):
|
||||
# Unlike other missing things, this must be an error otherwise
|
||||
# the MIME association fails which is the main purpose of registering types.
|
||||
return "Error: desktop file not found: {:s}".format(filepath_desktop_src)
|
||||
|
||||
os.makedirs(dirpath_dst, exist_ok=True)
|
||||
|
||||
with open(filepath_desktop_src, "r", encoding="utf-8") as fh:
|
||||
data = fh.read()
|
||||
|
||||
data = data.replace("\nExec=blender %f\n", "\nExec={:s} %f\n".format(BLENDER_BIN))
|
||||
|
||||
with open(filepath_desktop_dst, "w", encoding="utf-8") as fh:
|
||||
fh.write(data)
|
||||
return None
|
||||
|
||||
|
||||
def handle_thumbnailer(do_register: bool, all_users: bool) -> str | None:
|
||||
filename = "blender.thumbnailer"
|
||||
|
||||
if all_users:
|
||||
base_dir = os.path.join(SYSTEM_PREFIX, "share")
|
||||
else:
|
||||
base_dir = XDG_DATA_HOME
|
||||
|
||||
dirpath_dst = os.path.join(base_dir, "thumbnailers")
|
||||
filepath_thumbnailer_dst = os.path.join(dirpath_dst, filename)
|
||||
|
||||
if VERBOSE:
|
||||
sys.stdout.write("- {:s} thumbnailer: {:s}\n".format(
|
||||
("Setup" if do_register else "Remove"),
|
||||
filepath_repr(filepath_thumbnailer_dst),
|
||||
))
|
||||
|
||||
filepath_ensure_removed(filepath_thumbnailer_dst)
|
||||
if not do_register:
|
||||
return None
|
||||
|
||||
blender_thumbnailer_bin = os.path.join(BLENDER_DIR, BLENDER_THUMBNAILER_FILENAME)
|
||||
if not os.path.exists(blender_thumbnailer_bin):
|
||||
sys.stderr.write("Thumbnailer not found, this may not be a portable installation: {:s}\n".format(
|
||||
blender_thumbnailer_bin,
|
||||
))
|
||||
return None
|
||||
|
||||
os.makedirs(dirpath_dst, exist_ok=True)
|
||||
|
||||
# NOTE: unfortunately this can't be `blender_thumbnailer_bin` because GNOME calls the command
|
||||
# with wrapper that means the command *must* be in the users `$PATH`.
|
||||
# and it cannot be a SYMLINK.
|
||||
if shutil.which("bwrap") is not None:
|
||||
command = BLENDER_THUMBNAILER_FILENAME
|
||||
else:
|
||||
command = blender_thumbnailer_bin
|
||||
|
||||
with open(filepath_thumbnailer_dst, "w", encoding="utf-8") as fh:
|
||||
fh.write("[Thumbnailer Entry]\n")
|
||||
fh.write("TryExec={:s}\n".format(command))
|
||||
fh.write("Exec={:s} %i %o\n".format(command))
|
||||
fh.write("MimeType={:s};\n".format(BLENDER_MIME))
|
||||
return None
|
||||
|
||||
|
||||
def handle_mime_association_xml(do_register: bool, all_users: bool) -> str | None:
|
||||
# `xdg-mime install x-blender.xml`
|
||||
filename = "x-blender.xml"
|
||||
|
||||
if all_users:
|
||||
base_dir = os.path.join(SYSTEM_PREFIX, "share")
|
||||
else:
|
||||
base_dir = XDG_DATA_HOME
|
||||
|
||||
# Ensure directories exist `xdg-mime` will fail with an error if these don't exist.
|
||||
for dirpath_dst in (
|
||||
os.path.join(base_dir, "mime", "application"),
|
||||
os.path.join(base_dir, "mime", "packages")
|
||||
):
|
||||
os.makedirs(dirpath_dst, exist_ok=True)
|
||||
del dirpath_dst
|
||||
|
||||
# Unfortunately there doesn't seem to be a way to know the installed location.
|
||||
# Use hard-coded location.
|
||||
package_xml_dst = os.path.join(base_dir, "mime", "application", filename)
|
||||
|
||||
if VERBOSE:
|
||||
sys.stdout.write("- {:s} mime type: {:s}\n".format(
|
||||
("Setup" if do_register else "Remove"),
|
||||
filepath_repr(package_xml_dst),
|
||||
))
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
"XDG_DATA_DIRS": os.path.join(SYSTEM_PREFIX, "share")
|
||||
}
|
||||
|
||||
if not do_register:
|
||||
if not os.path.exists(package_xml_dst):
|
||||
return None
|
||||
# NOTE: `xdg-mime query default application/x-blender` could be used to check
|
||||
# if the XML is installed, however there is some slim chance the XML is installed
|
||||
# but the default doesn't point to Blender, just uninstall as it's harmless.
|
||||
cmd = (
|
||||
XDG_MIME_PROG,
|
||||
"uninstall",
|
||||
"--mode", "system" if all_users else "user",
|
||||
package_xml_dst,
|
||||
)
|
||||
subprocess.check_output(cmd, env=env)
|
||||
return None
|
||||
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
package_xml_src = os.path.join(tempdir, filename)
|
||||
with open(package_xml_src, mode="w", encoding="utf-8") as fh:
|
||||
fh.write("""<?xml version="1.0" encoding="UTF-8"?>\n""")
|
||||
fh.write("""<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">\n""")
|
||||
fh.write(""" <mime-type type="{:s}">\n""".format(BLENDER_MIME))
|
||||
# NOTE: not using a trailing full-stop seems to be the convention here.
|
||||
fh.write(""" <comment>Blender scene</comment>\n""")
|
||||
fh.write(""" <glob pattern="*.blend"/>\n""")
|
||||
# TODO: this doesn't seem to work, GNOME's Nautilus & KDE's Dolphin
|
||||
# already have a file-type icon for this so we might consider this low priority.
|
||||
if False:
|
||||
fh.write(""" <icon name="application-x-blender"/>\n""")
|
||||
fh.write(""" </mime-type>\n""")
|
||||
fh.write("""</mime-info>\n""")
|
||||
|
||||
cmd = (
|
||||
XDG_MIME_PROG,
|
||||
"install",
|
||||
"--mode", "system" if all_users else "user",
|
||||
package_xml_src,
|
||||
)
|
||||
subprocess.check_output(cmd, env=env)
|
||||
return None
|
||||
|
||||
|
||||
def handle_mime_association_default(do_register: bool, all_users: bool) -> str | None:
|
||||
# `xdg-mime default blender.desktop application/x-blender`
|
||||
|
||||
if VERBOSE:
|
||||
sys.stdout.write("- {:s} mime type as default\n".format(
|
||||
("Setup" if do_register else "Remove"),
|
||||
))
|
||||
|
||||
# NOTE: there doesn't seem to be a way to reverse this action.
|
||||
if not do_register:
|
||||
return None
|
||||
|
||||
cmd = (
|
||||
XDG_MIME_PROG,
|
||||
"default",
|
||||
BLENDER_DESKTOP,
|
||||
BLENDER_MIME,
|
||||
)
|
||||
subprocess.check_output(cmd)
|
||||
return None
|
||||
|
||||
|
||||
def handle_icon(do_register: bool, all_users: bool) -> str | None:
|
||||
filename = "blender.svg"
|
||||
if all_users:
|
||||
base_dir = os.path.join(SYSTEM_PREFIX, "share")
|
||||
else:
|
||||
base_dir = XDG_DATA_HOME
|
||||
|
||||
dirpath_dst = os.path.join(base_dir, "icons", "hicolor", "scalable", "apps")
|
||||
|
||||
filepath_desktop_src = os.path.join(BLENDER_DIR, filename)
|
||||
filepath_desktop_dst = os.path.join(dirpath_dst, filename)
|
||||
|
||||
if VERBOSE:
|
||||
sys.stdout.write("- {:s} icon: {:s}\n".format(
|
||||
("Setup" if do_register else "Remove"),
|
||||
filepath_repr(filepath_desktop_dst),
|
||||
))
|
||||
|
||||
filepath_ensure_removed(filepath_desktop_dst)
|
||||
if not do_register:
|
||||
return None
|
||||
|
||||
if not os.path.exists(filepath_desktop_src):
|
||||
sys.stderr.write(" Icon file not found, skipping: \"{:s}\"\n".format(filepath_desktop_src))
|
||||
# Not an error.
|
||||
return None
|
||||
|
||||
os.makedirs(dirpath_dst, exist_ok=True)
|
||||
|
||||
with open(filepath_desktop_src, "rb") as fh:
|
||||
data = fh.read()
|
||||
|
||||
with open(filepath_desktop_dst, "wb") as fh:
|
||||
fh.write(data)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Escalate Privileges
|
||||
|
||||
def main_run_as_root(
|
||||
do_register: bool,
|
||||
*,
|
||||
python_args: tuple[str, ...],
|
||||
) -> str | None:
|
||||
# If the system prefix doesn't exist, fail with an error because it's highly likely that the
|
||||
# system won't use this when it has not been created.
|
||||
if not os.path.exists(SYSTEM_PREFIX):
|
||||
return "Error: system path does not exist {!r}".format(SYSTEM_PREFIX)
|
||||
|
||||
prog: str | None = shutil.which("pkexec")
|
||||
if prog is None:
|
||||
return "Error: command \"pkexec\" not found"
|
||||
|
||||
python_args_extra = (
|
||||
# Skips users `site-packages` because they are only additional overhead for running this script.
|
||||
"-s",
|
||||
)
|
||||
|
||||
python_args = (
|
||||
*python_args,
|
||||
*(arg for arg in python_args_extra if arg not in python_args)
|
||||
)
|
||||
|
||||
cmd = [
|
||||
prog,
|
||||
sys.executable,
|
||||
*python_args,
|
||||
__file__,
|
||||
BLENDER_BIN,
|
||||
"--action={:s}".format("register-allusers" if do_register else "unregister-allusers"),
|
||||
]
|
||||
if VERBOSE:
|
||||
sys.stdout.write("Executing: {:s}\n".format(shlex.join(cmd)))
|
||||
|
||||
proc = subprocess.run(cmd, stderr=subprocess.PIPE)
|
||||
if proc.returncode != 0:
|
||||
if proc.stderr:
|
||||
return proc.stderr.decode("utf-8", errors="surrogateescape")
|
||||
return "Error: pkexec returned non-zero returncode"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Checked Call
|
||||
#
|
||||
# While exceptions should not happen, we can't entirely prevent this as it's always possible
|
||||
# a file write fails or a command doesn't work as expected anymore.
|
||||
# Handle these cases gracefully.
|
||||
|
||||
def call_handle_checked(
|
||||
fn: Callable[[bool, bool], str | None],
|
||||
*,
|
||||
do_register: bool,
|
||||
all_users: bool,
|
||||
) -> str | None:
|
||||
try:
|
||||
result = fn(do_register, all_users)
|
||||
except Exception as ex:
|
||||
# This should never happen.
|
||||
result = "Internal Error: {!r}".format(ex)
|
||||
return result
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main Registration Functions
|
||||
|
||||
def register_impl(do_register: bool, all_users: bool) -> str | None:
|
||||
# A non-empty string indicates an error (which is forwarded to the user), otherwise None for success.
|
||||
|
||||
global BLENDER_BIN
|
||||
global BLENDER_DIR
|
||||
|
||||
if BLENDER_ENV:
|
||||
# File association expects a "portable" build (see `WITH_INSTALL_PORTABLE` CMake option),
|
||||
# while it's possible support registering a "system" installation, the paths aren't located
|
||||
# relative to the blender binary and in general it's not needed because system installations
|
||||
# are used by package managers which can handle file association themselves.
|
||||
# The Linux builds provided by https://blender.org are portable, register is intended to be used for these.
|
||||
if not __import__("bpy").app.portable:
|
||||
return "System Installation, registration is handled by the package manager"
|
||||
# While snap builds are portable, the snap system handled file associations.
|
||||
# Blender is also launched via a wrapper, again, we could support this if it were
|
||||
# important but we can rely on the snap packaging in this case.
|
||||
if os.environ.get("SNAP"):
|
||||
return "Snap Package Installation, registration is handled by the package manager"
|
||||
|
||||
if BLENDER_ENV:
|
||||
# Only use of `bpy`.
|
||||
BLENDER_BIN = os.path.normpath(__import__("bpy").app.binary_path)
|
||||
|
||||
# Running inside Blender, detect the need for privilege escalation (which will run outside of Blender).
|
||||
if all_users:
|
||||
if os.geteuid() != 0:
|
||||
# Run this script with escalated privileges.
|
||||
return main_run_as_root(
|
||||
do_register,
|
||||
python_args=__import__("bpy").app.python_args,
|
||||
)
|
||||
else:
|
||||
assert BLENDER_BIN != ""
|
||||
|
||||
BLENDER_DIR = os.path.dirname(BLENDER_BIN)
|
||||
|
||||
if all_users:
|
||||
if not os.access(SYSTEM_PREFIX, os.W_OK):
|
||||
return "Error: {:s} not writable, this command may need to run as a superuser!".format(SYSTEM_PREFIX)
|
||||
|
||||
if VERBOSE:
|
||||
sys.stdout.write("{:s}: {:s}\n".format("Register" if do_register else "Unregister", BLENDER_BIN))
|
||||
|
||||
if XDG_MIME_PROG == "":
|
||||
return "Could not find \"xdg-mime\", unable to associate mime-types"
|
||||
|
||||
handlers = (
|
||||
handle_bin,
|
||||
handle_icon,
|
||||
handle_desktop_file,
|
||||
handle_mime_association_xml,
|
||||
# This only makes sense for users, although there may be a way to do this for all users.
|
||||
*(() if all_users else (handle_mime_association_default,)),
|
||||
# The thumbnailer only works when installed for all users.
|
||||
*((handle_thumbnailer,) if all_users else ()),
|
||||
)
|
||||
|
||||
error_or_none = None
|
||||
for i, fn in enumerate(handlers):
|
||||
if (error_or_none := call_handle_checked(fn, do_register=do_register, all_users=all_users)) is not None:
|
||||
break
|
||||
|
||||
if error_or_none is not None:
|
||||
# Roll back registration on failure.
|
||||
if do_register:
|
||||
for fn in reversed(handlers[:i + 1]):
|
||||
error_or_none_reverse = call_handle_checked(fn, do_register=False, all_users=all_users)
|
||||
if error_or_none_reverse is not None:
|
||||
sys.stdout.write("Error reverting action: {:s}\n".format(error_or_none_reverse))
|
||||
|
||||
# Print to the `stderr`, in case the user has a console open, it can be helpful
|
||||
# especially if it's multi-line.
|
||||
sys.stdout.write("{:s}\n".format(error_or_none))
|
||||
|
||||
return error_or_none
|
||||
|
||||
|
||||
def register(all_users: bool = False) -> str | None:
|
||||
# Return an empty string for success.
|
||||
return register_impl(True, all_users)
|
||||
|
||||
|
||||
def unregister(all_users: bool = False) -> str | None:
|
||||
# Return an empty string for success.
|
||||
return register_impl(False, all_users)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Running directly (Escalated Privileges)
|
||||
#
|
||||
# Needed when running as an administer.
|
||||
|
||||
register_actions = {
|
||||
"register": (True, False),
|
||||
"unregister": (False, False),
|
||||
"register-allusers": (True, True),
|
||||
"unregister-allusers": (False, True),
|
||||
}
|
||||
|
||||
|
||||
def argparse_create() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"blender_bin",
|
||||
metavar="BLENDER_BIN",
|
||||
type=str,
|
||||
help="The location of Blender's binary",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--action",
|
||||
choices=register_actions.keys(),
|
||||
dest="register_action",
|
||||
required=True,
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
global BLENDER_BIN
|
||||
assert BLENDER_BIN == ""
|
||||
args = argparse_create().parse_args()
|
||||
BLENDER_BIN = args.blender_bin
|
||||
do_register, all_users = register_actions[args.register_action]
|
||||
|
||||
if do_register:
|
||||
result = register(all_users=all_users)
|
||||
else:
|
||||
result = unregister(all_users=all_users)
|
||||
|
||||
if result:
|
||||
sys.stderr.write("{:s}\n".format(result))
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,258 @@
|
||||
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Function for extracting info from Blenders system information
|
||||
# (sometimes useful to include in bug reports).
|
||||
# Called by the operator `WM_OT_sysinfo`.
|
||||
|
||||
__all__ = (
|
||||
"write",
|
||||
)
|
||||
|
||||
|
||||
def write(output):
|
||||
# Writes into `output`, a file-like object.
|
||||
|
||||
import sys
|
||||
import platform
|
||||
|
||||
import subprocess
|
||||
|
||||
import bpy
|
||||
import gpu
|
||||
|
||||
# pretty repr
|
||||
def prepr(v):
|
||||
r = repr(v)
|
||||
vt = type(v)
|
||||
if vt is bytes:
|
||||
r = r[2:-1]
|
||||
elif vt is list or vt is tuple:
|
||||
r = r[1:-1]
|
||||
return r
|
||||
|
||||
header = "= Blender {:s} System Information =\n".format(bpy.app.version_string)
|
||||
lilies = "{:s}\n\n".format((len(header) - 1) * "=")
|
||||
output.write(lilies[:-1])
|
||||
output.write(header)
|
||||
output.write(lilies)
|
||||
|
||||
def title(text):
|
||||
return "\n{:s}:\n{:s}".format(text, lilies)
|
||||
|
||||
# build info
|
||||
output.write(title("Blender"))
|
||||
output.write(
|
||||
"version: {:s}, branch: {:s}, commit date: {:s} {:s}, hash: {:s}, type: {:s}\n".format(
|
||||
bpy.app.version_string,
|
||||
prepr(bpy.app.build_branch),
|
||||
prepr(bpy.app.build_commit_date),
|
||||
prepr(bpy.app.build_commit_time),
|
||||
prepr(bpy.app.build_hash),
|
||||
prepr(bpy.app.build_type),
|
||||
)
|
||||
)
|
||||
|
||||
output.write("build date: {:s}, {:s}\n".format(prepr(bpy.app.build_date), prepr(bpy.app.build_time)))
|
||||
output.write("platform: {:s}\n".format(prepr(platform.platform())))
|
||||
output.write("binary path: {:s}\n".format(prepr(bpy.app.binary_path)))
|
||||
output.write("build cflags: {:s}\n".format(prepr(bpy.app.build_cflags)))
|
||||
output.write("build cxxflags: {:s}\n".format(prepr(bpy.app.build_cxxflags)))
|
||||
output.write("build linkflags: {:s}\n".format(prepr(bpy.app.build_linkflags)))
|
||||
output.write("build system: {:s}\n".format(prepr(bpy.app.build_system)))
|
||||
|
||||
# Windowing Environment (include when dynamically selectable).
|
||||
from _bpy import _ghost_backend
|
||||
ghost_backend = _ghost_backend()
|
||||
if ghost_backend not in {'NONE', 'DEFAULT'}:
|
||||
output.write("windowing environment: {:s}\n".format(prepr(ghost_backend)))
|
||||
del _ghost_backend, ghost_backend
|
||||
|
||||
# Python info.
|
||||
output.write(title("Python"))
|
||||
output.write("version: {:s}\n".format(sys.version.replace("\n", " ")))
|
||||
output.write("file system encoding: {:s}:{:s}\n".format(
|
||||
sys.getfilesystemencoding(),
|
||||
sys.getfilesystemencodeerrors(),
|
||||
))
|
||||
output.write("paths:\n")
|
||||
for p in sys.path:
|
||||
output.write("\t{!r}\n".format(p))
|
||||
|
||||
output.write(title("Python (External Binary)"))
|
||||
output.write("binary path: {:s}\n".format(prepr(sys.executable)))
|
||||
try:
|
||||
py_ver = prepr(subprocess.check_output([
|
||||
sys.executable,
|
||||
"--version",
|
||||
]).strip())
|
||||
except Exception as ex:
|
||||
py_ver = str(ex)
|
||||
output.write("version: {:s}\n".format(py_ver))
|
||||
del py_ver
|
||||
|
||||
output.write(title("Directories"))
|
||||
output.write("scripts:\n")
|
||||
for p in bpy.utils.script_paths():
|
||||
output.write("\t{!r}\n".format(p))
|
||||
output.write("user scripts: {!r}\n".format(bpy.utils.script_path_user()))
|
||||
output.write("pref scripts:\n")
|
||||
for p in bpy.utils.script_paths_pref():
|
||||
output.write("\t{!r}\n".format(p))
|
||||
output.write("datafiles: {!r}\n".format(bpy.utils.user_resource('DATAFILES')))
|
||||
output.write("config: {!r}\n".format(bpy.utils.user_resource('CONFIG')))
|
||||
output.write("scripts: {!r}\n".format(bpy.utils.user_resource('SCRIPTS')))
|
||||
output.write("extensions: {!r}\n".format(bpy.utils.user_resource('EXTENSIONS')))
|
||||
output.write("tempdir: {!r}\n".format(bpy.app.tempdir))
|
||||
|
||||
output.write(title("FFmpeg"))
|
||||
ffmpeg = bpy.app.ffmpeg
|
||||
if ffmpeg.supported:
|
||||
for lib in ("avcodec", "avdevice", "avformat", "avutil", "swscale"):
|
||||
output.write(
|
||||
"{:s}:{:s}{!r}\n".format(
|
||||
lib,
|
||||
" " * (10 - len(lib)),
|
||||
getattr(ffmpeg, lib + "_version_string"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
output.write("Blender was built without FFmpeg support\n")
|
||||
|
||||
if bpy.app.build_options.sdl:
|
||||
output.write(title("SDL"))
|
||||
output.write("Version: {:s}\n".format(bpy.app.sdl.version_string))
|
||||
|
||||
output.write(title("Other Libraries"))
|
||||
ocio = bpy.app.ocio
|
||||
output.write("OpenColorIO: ")
|
||||
if ocio.supported:
|
||||
if ocio.version_string == "fallback":
|
||||
output.write(
|
||||
"Blender was built with OpenColorIO, "
|
||||
"but it currently uses fallback color management.\n"
|
||||
)
|
||||
else:
|
||||
output.write("{:s}\n".format(ocio.version_string))
|
||||
else:
|
||||
output.write("Blender was built without OpenColorIO support\n")
|
||||
|
||||
oiio = bpy.app.oiio
|
||||
output.write("OpenImageIO: ")
|
||||
if ocio.supported:
|
||||
output.write("{:s}\n".format(oiio.version_string))
|
||||
else:
|
||||
output.write("Blender was built without OpenImageIO support\n")
|
||||
|
||||
output.write("OpenShadingLanguage: ")
|
||||
if bpy.app.build_options.cycles:
|
||||
if bpy.app.build_options.cycles_osl:
|
||||
from _cycles import osl_version_string
|
||||
output.write("{:s}\n".format(osl_version_string))
|
||||
else:
|
||||
output.write("Blender was built without OpenShadingLanguage support in Cycles\n")
|
||||
else:
|
||||
output.write("Blender was built without Cycles support\n")
|
||||
|
||||
opensubdiv = bpy.app.opensubdiv
|
||||
output.write("OpenSubdiv: ")
|
||||
if opensubdiv.supported:
|
||||
output.write("{:s}\n".format(opensubdiv.version_string))
|
||||
else:
|
||||
output.write("Blender was built without OpenSubdiv support\n")
|
||||
|
||||
openvdb = bpy.app.openvdb
|
||||
output.write("OpenVDB: ")
|
||||
if openvdb.supported:
|
||||
output.write("{:s}\n".format(openvdb.version_string))
|
||||
else:
|
||||
output.write("Blender was built without OpenVDB support\n")
|
||||
|
||||
alembic = bpy.app.alembic
|
||||
output.write("Alembic: ")
|
||||
if alembic.supported:
|
||||
output.write("{:s}\n".format(alembic.version_string))
|
||||
else:
|
||||
output.write("Blender was built without Alembic support\n")
|
||||
|
||||
usd = bpy.app.usd
|
||||
output.write("USD: ")
|
||||
if usd.supported:
|
||||
output.write("{:s}\n".format(usd.version_string))
|
||||
else:
|
||||
output.write("Blender was built without USD support\n")
|
||||
|
||||
if not bpy.app.build_options.sdl:
|
||||
output.write("SDL: Blender was built without SDL support\n")
|
||||
|
||||
if bpy.app.background:
|
||||
output.write("\nGPU: missing, background mode\n")
|
||||
else:
|
||||
output.write(title("GPU"))
|
||||
output.write("renderer:\t{!r}\n".format(gpu.platform.renderer_get()))
|
||||
output.write("vendor:\t\t{!r}\n".format(gpu.platform.vendor_get()))
|
||||
output.write("version:\t{!r}\n".format(gpu.platform.version_get()))
|
||||
output.write("device type:\t{!r}\n".format(gpu.platform.device_type_get()))
|
||||
output.write("backend type:\t{!r}\n".format(gpu.platform.backend_type_get()))
|
||||
output.write("extensions:\n")
|
||||
|
||||
glext = sorted(gpu.capabilities.extensions_get())
|
||||
|
||||
for line in glext:
|
||||
output.write("\t{:s}\n".format(line))
|
||||
|
||||
output.write(title("Implementation Dependent GPU Limits"))
|
||||
output.write("Maximum Batch Vertices:\t{:d}\n".format(
|
||||
gpu.capabilities.max_batch_vertices_get(),
|
||||
))
|
||||
output.write("Maximum Batch Indices:\t{:d}\n".format(
|
||||
gpu.capabilities.max_batch_indices_get(),
|
||||
))
|
||||
|
||||
output.write("\nGLSL:\n")
|
||||
output.write("Maximum Varying Floats:\t{:d}\n".format(
|
||||
gpu.capabilities.max_varying_floats_get(),
|
||||
))
|
||||
output.write("Maximum Vertex Attributes:\t{:d}\n".format(
|
||||
gpu.capabilities.max_vertex_attribs_get(),
|
||||
))
|
||||
output.write("Maximum Vertex Uniform Components:\t{:d}\n".format(
|
||||
gpu.capabilities.max_uniforms_vert_get(),
|
||||
))
|
||||
output.write("Maximum Fragment Uniform Components:\t{:d}\n".format(
|
||||
gpu.capabilities.max_uniforms_frag_get(),
|
||||
))
|
||||
output.write("Maximum Vertex Image Units:\t{:d}\n".format(
|
||||
gpu.capabilities.max_textures_vert_get(),
|
||||
))
|
||||
output.write("Maximum Fragment Image Units:\t{:d}\n".format(
|
||||
gpu.capabilities.max_textures_frag_get(),
|
||||
))
|
||||
output.write("Maximum Pipeline Image Units:\t{:d}\n".format(
|
||||
gpu.capabilities.max_textures_get(),
|
||||
))
|
||||
output.write("Maximum Image Units:\t{:d}\n".format(
|
||||
gpu.capabilities.max_images_get(),
|
||||
))
|
||||
|
||||
if bpy.app.build_options.cycles:
|
||||
import cycles
|
||||
output.write(title("Cycles"))
|
||||
output.write(cycles.engine.system_info())
|
||||
|
||||
import addon_utils
|
||||
addon_utils.modules()
|
||||
output.write(title("Enabled add-ons"))
|
||||
for addon in bpy.context.preferences.addons.keys():
|
||||
addon_mod = addon_utils.addons_fake_modules.get(addon, None)
|
||||
if addon_mod is None:
|
||||
output.write("{:s} (MISSING)\n".format(addon))
|
||||
else:
|
||||
output.write(
|
||||
"{:s} (version: {:s}, path: {!r})\n".format(
|
||||
addon,
|
||||
str(addon_mod.bl_info.get("version", "UNKNOWN")),
|
||||
addon_mod.__file__,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-FileCopyrightText: 2019-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Keep the information collected in this script synchronized with `startup.py`.
|
||||
|
||||
__all__ = (
|
||||
"url_from_blender",
|
||||
)
|
||||
|
||||
|
||||
def url_from_blender():
|
||||
import bpy
|
||||
import gpu
|
||||
import struct
|
||||
import platform
|
||||
import urllib.parse
|
||||
|
||||
query_params = {
|
||||
"type": "bug_report",
|
||||
"project": "blender",
|
||||
}
|
||||
|
||||
query_params["os"] = "{:s} {:d} Bits".format(
|
||||
platform.platform(),
|
||||
struct.calcsize("P") * 8,
|
||||
)
|
||||
|
||||
# Windowing Environment (include when dynamically selectable).
|
||||
# This lets us know if WAYLAND/X11 is in use.
|
||||
from _bpy import _ghost_backend
|
||||
ghost_backend = _ghost_backend()
|
||||
if ghost_backend not in {'NONE', 'DEFAULT'}:
|
||||
query_params["os"] += (", {:s} UI".format(ghost_backend))
|
||||
del _ghost_backend, ghost_backend
|
||||
|
||||
query_params["gpu"] = "{:s} {:s} {:s}".format(
|
||||
gpu.platform.renderer_get(),
|
||||
gpu.platform.vendor_get(),
|
||||
gpu.platform.version_get(),
|
||||
)
|
||||
|
||||
gpu_backend = gpu.platform.backend_type_get()
|
||||
if gpu_backend not in {'NONE', 'UNKNOWN', 'METAL'}:
|
||||
query_params["gpu"] += (" {:s} Backend".format(gpu_backend.title()))
|
||||
|
||||
query_params["broken_version"] = "{:s}, branch: {:s}, commit date: {:s} {:s}, hash: `{:s}`".format(
|
||||
bpy.app.version_string,
|
||||
bpy.app.build_branch.decode('utf-8', 'replace'),
|
||||
bpy.app.build_commit_date.decode('utf-8', 'replace'),
|
||||
bpy.app.build_commit_time.decode('utf-8', 'replace'),
|
||||
bpy.app.build_hash.decode('ascii'),
|
||||
)
|
||||
|
||||
query_str = urllib.parse.urlencode(query_params)
|
||||
return "https://redirect.blender.org/?" + query_str
|
||||
107
blender-5.2.0/scripts/modules/_bpy_internal/system_info/url_prefill_startup.py
Executable file
107
blender-5.2.0/scripts/modules/_bpy_internal/system_info/url_prefill_startup.py
Executable file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2024 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Keep the information collected in this script synchronized with `runtime.py`.
|
||||
|
||||
# NOTE: this can run as a standalone script, called directly from Python
|
||||
# (even though it's located inside a package).
|
||||
|
||||
__all__ = (
|
||||
"url_from_blender",
|
||||
)
|
||||
|
||||
|
||||
def url_from_blender() -> str:
|
||||
import re
|
||||
import struct
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
print("Collecting system information...")
|
||||
|
||||
query_params = {"type": "bug_report", "project": "blender"}
|
||||
|
||||
query_params["os"] = "{:s} {:d} Bits".format(
|
||||
platform.platform(),
|
||||
struct.calcsize("P") * 8,
|
||||
)
|
||||
|
||||
# There doesn't appear to be a easy way to collect GPU information in Python
|
||||
# if Blender isn't opening and we can't import the GPU module.
|
||||
# So just tell users to follow a written guide.
|
||||
query_params["gpu"] = (
|
||||
"Follow our guide to collect this information:\n"
|
||||
"https://developer.blender.org/docs/handbook/bug_reports/making_good_bug_reports/collect_system_information/"
|
||||
)
|
||||
|
||||
os_type = platform.system()
|
||||
script_directory = Path(__file__).parent.resolve()
|
||||
if os_type == "Darwin": # macOS appears as Darwin.
|
||||
blender_bin = script_directory.joinpath("../../../../../../MacOS/Blender")
|
||||
elif os_type == "Windows":
|
||||
blender_bin = script_directory.joinpath("../../../../../Blender.exe")
|
||||
else: # Linux and other Unix systems.
|
||||
blender_bin = script_directory.joinpath("../../../../../blender")
|
||||
|
||||
try:
|
||||
blender_output = subprocess.run(
|
||||
(blender_bin, "--version"),
|
||||
stdout=subprocess.PIPE,
|
||||
encoding="utf-8",
|
||||
errors="surrogateescape",
|
||||
)
|
||||
except Exception as ex:
|
||||
sys.stderr.write("{:s}\n".format(str(ex)))
|
||||
return ""
|
||||
|
||||
text = blender_output.stdout
|
||||
|
||||
unknown_string = "<unknown>"
|
||||
|
||||
def re_group_or_unknown(m: re.Match[str] | None) -> str:
|
||||
if m is None:
|
||||
return unknown_string
|
||||
return m.group(1)
|
||||
|
||||
# Gather Blender version information.
|
||||
values: dict[str, str] = {
|
||||
"version": re_group_or_unknown(re.search(r"^Blender (.*)", text, flags=re.MULTILINE)),
|
||||
"branch": re_group_or_unknown(re.search(r"^\s+build branch: (.*)", text, flags=re.MULTILINE)),
|
||||
"commit_date": re_group_or_unknown(re.search(r"^\s+build commit date: (.*)", text, flags=re.MULTILINE)),
|
||||
"commit_time": re_group_or_unknown(re.search(r"^\s+build commit time: (.*)", text, flags=re.MULTILINE)),
|
||||
"build_hash": re_group_or_unknown(re.search(r"^\s+build hash: (.*)", text, flags=re.MULTILINE)),
|
||||
}
|
||||
|
||||
if not (set(values.values()) - {unknown_string}):
|
||||
# No valid Blender info could be found.
|
||||
print("Blender did not provide any build information. Blender may be corrupt or blocked from running.")
|
||||
print("Please try reinstalling Blender and double check your anti-virus isn't blocking it from running.")
|
||||
return ""
|
||||
|
||||
query_params["broken_version"] = (
|
||||
"{version:s}, branch: {branch:s}, commit date: {commit_date:s} {commit_time:s}, hash `{build_hash:s}`".format(
|
||||
**values,
|
||||
)
|
||||
)
|
||||
|
||||
return "https://redirect.blender.org/?{:s}".format(urllib.parse.urlencode(query_params))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import webbrowser
|
||||
|
||||
if not (url := url_from_blender()):
|
||||
return 1
|
||||
|
||||
webbrowser.open(url)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.exit(main())
|
||||
50
blender-5.2.0/scripts/modules/_bpy_restrict_state.py
Normal file
50
blender-5.2.0/scripts/modules/_bpy_restrict_state.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This module contains RestrictBlend context manager.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"RestrictBlend",
|
||||
)
|
||||
|
||||
import bpy as _bpy
|
||||
|
||||
|
||||
class _RestrictContext:
|
||||
__slots__ = ()
|
||||
_real_data = _bpy.data
|
||||
# safe, the pointer never changes
|
||||
_real_pref = _bpy.context.preferences
|
||||
|
||||
@property
|
||||
def window_manager(self):
|
||||
return self._real_data.window_managers[0]
|
||||
|
||||
@property
|
||||
def preferences(self):
|
||||
return self._real_pref
|
||||
|
||||
|
||||
class _RestrictData:
|
||||
__slots__ = ()
|
||||
|
||||
|
||||
_context_restrict = _RestrictContext()
|
||||
_data_restrict = _RestrictData()
|
||||
|
||||
|
||||
class RestrictBlend:
|
||||
__slots__ = ("context", "data")
|
||||
|
||||
def __enter__(self):
|
||||
self.data = _bpy.data
|
||||
self.context = _bpy.context
|
||||
_bpy.data = _data_restrict
|
||||
_bpy.context = _context_restrict
|
||||
|
||||
def __exit__(self, _type, _value, _traceback):
|
||||
_bpy.data = self.data
|
||||
_bpy.context = self.context
|
||||
1770
blender-5.2.0/scripts/modules/_bpy_types.py
Normal file
1770
blender-5.2.0/scripts/modules/_bpy_types.py
Normal file
File diff suppressed because it is too large
Load Diff
368
blender-5.2.0/scripts/modules/_console_python.py
Normal file
368
blender-5.2.0/scripts/modules/_console_python.py
Normal file
@@ -0,0 +1,368 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"autocomplete",
|
||||
"banner",
|
||||
"execute",
|
||||
"language_id",
|
||||
)
|
||||
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
language_id = "python"
|
||||
|
||||
|
||||
class _TempModuleOverride:
|
||||
__slots__ = (
|
||||
"module_name",
|
||||
"module",
|
||||
"module_override",
|
||||
)
|
||||
|
||||
def __init__(self, module_name, module_override):
|
||||
self.module_name = module_name
|
||||
self.module = None
|
||||
self.module_override = module_override
|
||||
|
||||
def __enter__(self):
|
||||
self.module = sys.modules.get(self.module_name)
|
||||
sys.modules[self.module_name] = self.module_override
|
||||
|
||||
def __exit__(self, _type, _value, _traceback):
|
||||
if self.module is None:
|
||||
# Account for removal of `module_override` (albeit unlikely).
|
||||
sys.modules.pop(self.module_name, None)
|
||||
else:
|
||||
sys.modules[self.module_name] = self.module
|
||||
|
||||
|
||||
def add_scrollback(text, text_type):
|
||||
for line in text.split("\n"):
|
||||
bpy.ops.console.scrollback_append(
|
||||
text=line,
|
||||
type=text_type,
|
||||
)
|
||||
|
||||
|
||||
def replace_help(namespace):
|
||||
def _help(*args):
|
||||
# because of how the console works. we need our own help() pager func.
|
||||
# replace the bold function because it adds crazy chars
|
||||
import pydoc
|
||||
pydoc.getpager = lambda: pydoc.plainpager
|
||||
pydoc.Helper.getline = lambda self, prompt: None
|
||||
pydoc.TextDoc.use_bold = lambda self, text: text
|
||||
|
||||
pydoc.help(*args)
|
||||
|
||||
namespace["help"] = _help
|
||||
|
||||
|
||||
def get_console(console_id):
|
||||
"""
|
||||
helper function for console operators
|
||||
currently each text data block gets its own
|
||||
console - code.InteractiveConsole()
|
||||
...which is stored in this function.
|
||||
|
||||
console_id can be any hashable type
|
||||
"""
|
||||
from code import InteractiveConsole
|
||||
|
||||
consoles = getattr(get_console, "consoles", None)
|
||||
hash_next = hash(bpy.context.window_manager)
|
||||
|
||||
if consoles is None:
|
||||
consoles = get_console.consoles = {}
|
||||
get_console.consoles_namespace_hash = hash_next
|
||||
else:
|
||||
# check if clearing the namespace is needed to avoid a memory leak.
|
||||
# the window manager is normally loaded with new blend files
|
||||
# so this is a reasonable way to deal with namespace clearing.
|
||||
# bpy.data hashing is reset by undo so can't be used.
|
||||
hash_prev = getattr(get_console, "consoles_namespace_hash", 0)
|
||||
|
||||
if hash_prev != hash_next:
|
||||
get_console.consoles_namespace_hash = hash_next
|
||||
consoles.clear()
|
||||
|
||||
console_data = consoles.get(console_id)
|
||||
|
||||
if console_data:
|
||||
console, stdout, stderr = console_data
|
||||
|
||||
# XXX, bug in python 3.1.2, 3.2 ? (worked in 3.1.1)
|
||||
# seems there is no way to clear StringIO objects for writing, have to
|
||||
# make new ones each time.
|
||||
import io
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
else:
|
||||
import types
|
||||
bpy_main_mod = types.ModuleType("__main__")
|
||||
namespace = bpy_main_mod.__dict__
|
||||
|
||||
namespace["__builtins__"] = sys.modules["builtins"]
|
||||
namespace["bpy"] = bpy
|
||||
|
||||
# weak! - but highly convenient
|
||||
namespace["C"] = bpy.context
|
||||
namespace["D"] = bpy.data
|
||||
|
||||
replace_help(namespace)
|
||||
|
||||
console = InteractiveConsole(locals=namespace,
|
||||
filename="<blender_console>")
|
||||
|
||||
console.push("from mathutils import *")
|
||||
console.push("from math import *")
|
||||
|
||||
console._bpy_main_mod = bpy_main_mod
|
||||
|
||||
import io
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
|
||||
consoles[console_id] = console, stdout, stderr
|
||||
|
||||
return console, stdout, stderr
|
||||
|
||||
|
||||
# Both prompts must be the same length
|
||||
PROMPT = '>>> '
|
||||
PROMPT_MULTI = '... '
|
||||
|
||||
|
||||
def execute(context, is_interactive):
|
||||
sc = context.space_data
|
||||
|
||||
try:
|
||||
line_object = sc.history[-1]
|
||||
except:
|
||||
return {'CANCELLED'}
|
||||
|
||||
console, stdout, stderr = get_console(hash(context.region))
|
||||
|
||||
# redirect output
|
||||
from contextlib import (
|
||||
redirect_stdout,
|
||||
redirect_stderr,
|
||||
)
|
||||
|
||||
# Not included with Python.
|
||||
class redirect_stdin(redirect_stdout.__base__):
|
||||
_stream = "stdin"
|
||||
|
||||
with (
|
||||
redirect_stdout(stdout),
|
||||
redirect_stderr(stderr),
|
||||
# Don't allow the `stdin` to be used because it can lock Blender.
|
||||
redirect_stdin(None),
|
||||
_TempModuleOverride("__main__", console._bpy_main_mod),
|
||||
):
|
||||
# in case exception happens
|
||||
line = "" # in case of encoding error
|
||||
is_multiline = False
|
||||
|
||||
try:
|
||||
line = line_object.body
|
||||
|
||||
# run the console, "\n" executes a multi line statement
|
||||
line_exec = line if line.strip() else "\n"
|
||||
|
||||
is_multiline = console.push(line_exec)
|
||||
except SystemExit as ex:
|
||||
# Occurs when `exit(..)` is called, this raises an exception instead of exiting.
|
||||
# The trace-back isn't helpful in this case, just print the exception.
|
||||
stderr.write("{!r}\n".format(ex))
|
||||
# Without this, entering new commands may include the previous command, see: #109435.
|
||||
console.resetbuffer()
|
||||
except:
|
||||
# Unlikely, but this can happen with unicode errors accessing `line_object.body`.
|
||||
import traceback
|
||||
stderr.write(traceback.format_exc())
|
||||
|
||||
output = stdout.getvalue()
|
||||
output_err = stderr.getvalue()
|
||||
|
||||
# cleanup
|
||||
sys.last_traceback = None
|
||||
|
||||
# So we can reuse, clear all data
|
||||
stdout.truncate(0)
|
||||
stderr.truncate(0)
|
||||
|
||||
# special exception. its possible the command loaded a new user interface
|
||||
if hash(sc) != hash(context.space_data):
|
||||
return {'FINISHED'}
|
||||
|
||||
bpy.ops.console.scrollback_append(text=sc.prompt + line, type='INPUT')
|
||||
|
||||
if is_multiline:
|
||||
sc.prompt = PROMPT_MULTI
|
||||
if is_interactive:
|
||||
indent = line[:len(line) - len(line.lstrip())]
|
||||
if line.rstrip().endswith(":"):
|
||||
indent += " "
|
||||
else:
|
||||
indent = ""
|
||||
else:
|
||||
sc.prompt = PROMPT
|
||||
indent = ""
|
||||
|
||||
# insert a new blank line
|
||||
bpy.ops.console.history_append(text=indent, current_character=0,
|
||||
remove_duplicates=True)
|
||||
sc.history[-1].current_character = len(indent)
|
||||
|
||||
# Insert the output into the editor
|
||||
# not quite correct because the order might have changed,
|
||||
# but ok 99% of the time.
|
||||
if output:
|
||||
add_scrollback(output, 'OUTPUT')
|
||||
if output_err:
|
||||
add_scrollback(output_err, 'ERROR')
|
||||
|
||||
# execute any hooks
|
||||
for func, args in execute.hooks:
|
||||
func(*args)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
execute.hooks = []
|
||||
|
||||
|
||||
def autocomplete(context):
|
||||
from _bl_console_utils.autocomplete import intellisense
|
||||
|
||||
sc = context.space_data
|
||||
|
||||
console = get_console(hash(context.region))[0]
|
||||
|
||||
if not console:
|
||||
return {'CANCELLED'}
|
||||
|
||||
scrollback = ""
|
||||
scrollback_error = ""
|
||||
|
||||
# Don't allow the `stdin` to be used, can lock blender.
|
||||
# note: unlikely `stdin` would be used for auto-complete - but it's possible.
|
||||
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
# Not included with Python.
|
||||
class redirect_stdin(redirect_stdout.__base__):
|
||||
_stream = "stdin"
|
||||
|
||||
with (
|
||||
# Don't allow the `stdin` to be used because it can lock Blender.
|
||||
redirect_stdin(None),
|
||||
_TempModuleOverride("__main__", console._bpy_main_mod),
|
||||
):
|
||||
try:
|
||||
current_line = sc.history[-1]
|
||||
line = current_line.body
|
||||
|
||||
# This function isn't aware of the text editor or being an operator
|
||||
# just does the autocomplete then copy its results back
|
||||
result = intellisense.expand(
|
||||
line=line,
|
||||
cursor=current_line.current_character,
|
||||
namespace=console.locals,
|
||||
private=bpy.app.debug_python)
|
||||
|
||||
line_new = result[0]
|
||||
current_line.body, current_line.current_character, scrollback = result
|
||||
del result
|
||||
|
||||
# update selection. setting body should really do this!
|
||||
ofs = len(line_new) - len(line)
|
||||
sc.select_start += ofs
|
||||
sc.select_end += ofs
|
||||
except:
|
||||
# unlikely, but this can happen with unicode errors for example.
|
||||
# or if the API attribute access itself causes an error.
|
||||
import traceback
|
||||
scrollback_error = traceback.format_exc()
|
||||
|
||||
# Separate autocomplete output by command prompts
|
||||
if scrollback != '':
|
||||
bpy.ops.console.scrollback_append(text=sc.prompt + current_line.body,
|
||||
type='INPUT')
|
||||
|
||||
# Now we need to copy back the line from blender back into the
|
||||
# text editor. This will change when we don't use the text editor
|
||||
# anymore
|
||||
if scrollback:
|
||||
add_scrollback(scrollback, 'INFO')
|
||||
|
||||
if scrollback_error:
|
||||
add_scrollback(scrollback_error, 'ERROR')
|
||||
|
||||
context.area.tag_redraw()
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
def copy_as_script(context):
|
||||
sc = context.space_data
|
||||
lines = [
|
||||
"import bpy",
|
||||
"from bpy import data as D",
|
||||
"from bpy import context as C",
|
||||
"from mathutils import *",
|
||||
"from math import *",
|
||||
"",
|
||||
]
|
||||
|
||||
for line in sc.scrollback:
|
||||
text = line.body
|
||||
type = line.type
|
||||
|
||||
if type == 'INFO': # Ignore auto-completion.
|
||||
continue
|
||||
if type == 'INPUT':
|
||||
if text.startswith(PROMPT):
|
||||
text = text[len(PROMPT):]
|
||||
elif text.startswith(PROMPT_MULTI):
|
||||
text = text[len(PROMPT_MULTI):]
|
||||
elif type == 'OUTPUT':
|
||||
text = "#~ " + text
|
||||
elif type == 'ERROR':
|
||||
text = "#! " + text
|
||||
|
||||
lines.append(text)
|
||||
|
||||
context.window_manager.clipboard = "\n".join(lines)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
def banner(context):
|
||||
sc = context.space_data
|
||||
version_string = sys.version.strip().replace('\n', ' ')
|
||||
|
||||
message = (
|
||||
"PYTHON INTERACTIVE CONSOLE {:s}".format(version_string),
|
||||
"",
|
||||
"Builtin Modules: "
|
||||
"bpy, bpy.data, bpy.ops, bpy.props, bpy.types, bpy.context, bpy.utils, gpu, blf, mathutils",
|
||||
|
||||
"Convenience Imports: from mathutils import *; from math import *",
|
||||
"Convenience Variables: C = bpy.context, D = bpy.data",
|
||||
"",
|
||||
)
|
||||
|
||||
# NOTE: Using `OUTPUT` style (intended for the `stdout` is also valid).
|
||||
# Using `INFO` has a slight advantage that it's excluded by the "Copy as Script" operator.
|
||||
# As the banner isn't useful to include in a script - leave it out.
|
||||
for line in message:
|
||||
add_scrollback(line, 'INFO')
|
||||
|
||||
sc.prompt = PROMPT
|
||||
|
||||
return {'FINISHED'}
|
||||
73
blender-5.2.0/scripts/modules/_console_shell.py
Normal file
73
blender-5.2.0/scripts/modules/_console_shell.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"autocomplete",
|
||||
"banner",
|
||||
"execute",
|
||||
"language_id",
|
||||
)
|
||||
|
||||
import os
|
||||
import bpy
|
||||
|
||||
language_id = "shell"
|
||||
|
||||
|
||||
def add_scrollback(text, text_type):
|
||||
for line in text.split("\n"):
|
||||
bpy.ops.console.scrollback_append(
|
||||
text=line.replace("\t", " "),
|
||||
type=text_type,
|
||||
)
|
||||
|
||||
|
||||
def shell_run(text):
|
||||
import subprocess
|
||||
val, output = subprocess.getstatusoutput(text)
|
||||
|
||||
if not val:
|
||||
style = 'OUTPUT'
|
||||
else:
|
||||
style = 'ERROR'
|
||||
|
||||
add_scrollback(output, style)
|
||||
|
||||
|
||||
PROMPT = "$ "
|
||||
|
||||
|
||||
def execute(context, _is_interactive):
|
||||
sc = context.space_data
|
||||
|
||||
try:
|
||||
line = sc.history[-1].body
|
||||
except:
|
||||
return {'CANCELLED'}
|
||||
|
||||
bpy.ops.console.scrollback_append(text=sc.prompt + line, type='INPUT')
|
||||
|
||||
shell_run(line)
|
||||
|
||||
# insert a new blank line
|
||||
bpy.ops.console.history_append(text="", current_character=0,
|
||||
remove_duplicates=True)
|
||||
|
||||
sc.prompt = os.getcwd() + PROMPT
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
def autocomplete(_context):
|
||||
# sc = context.space_data
|
||||
# TODO
|
||||
return {'CANCELLED'}
|
||||
|
||||
|
||||
def banner(context):
|
||||
sc = context.space_data
|
||||
|
||||
shell_run("bash --version")
|
||||
sc.prompt = os.getcwd() + PROMPT
|
||||
|
||||
return {'FINISHED'}
|
||||
194
blender-5.2.0/scripts/modules/_graphviz_export.py
Normal file
194
blender-5.2.0/scripts/modules/_graphviz_export.py
Normal file
@@ -0,0 +1,194 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import bpy
|
||||
|
||||
header = '''
|
||||
digraph ancestors {
|
||||
graph [fontsize=30 labelloc="t" label="" splines=false overlap=true, rankdir=BT];
|
||||
ratio = "auto" ;
|
||||
'''
|
||||
|
||||
footer = '''
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def compat_str(text, line_length=0):
|
||||
|
||||
if line_length:
|
||||
text_ls = []
|
||||
while len(text) > line_length:
|
||||
text_ls.append(text[:line_length])
|
||||
text = text[line_length:]
|
||||
|
||||
if text:
|
||||
text_ls.append(text)
|
||||
text = '\n '.join(text_ls)
|
||||
|
||||
# text = text.replace(".", ".\n")
|
||||
# text = text.replace("]", "]\n")
|
||||
text = text.replace("\n", "\\n")
|
||||
text = text.replace('"', '\\"')
|
||||
return text
|
||||
|
||||
|
||||
def graph_armature(obj, filepath, FAKE_PARENT=True, CONSTRAINTS=True, DRIVERS=True, XTRA_INFO=True):
|
||||
CONSTRAINTS = DRIVERS = True
|
||||
|
||||
fileobject = open(filepath, "w")
|
||||
fw = fileobject.write
|
||||
fw(header)
|
||||
fw('label = "{:s}::{:s}" ;'.format(bpy.data.filepath.split("/")[-1].split("\\")[-1], obj.name))
|
||||
|
||||
arm = obj.data
|
||||
|
||||
bones = [bone.name for bone in arm.bones]
|
||||
bones.sort()
|
||||
print("")
|
||||
for bone in bones:
|
||||
b = arm.bones[bone]
|
||||
print(">>", bone, ["*>", "->"][b.use_connect], getattr(getattr(b, "parent", ""), "name", ""))
|
||||
label = [bone]
|
||||
bone = arm.bones[bone]
|
||||
|
||||
for key, value in obj.pose.bones[bone.name].items():
|
||||
if key.startswith("_"):
|
||||
continue
|
||||
|
||||
value_type = type(value)
|
||||
if value_type is float:
|
||||
value = "{:.3f}".format(value)
|
||||
elif value_type is str:
|
||||
value = compat_str(value)
|
||||
|
||||
label.append("{:s} = {:s}".format(key, value))
|
||||
|
||||
opts = [
|
||||
"shape=box",
|
||||
"regular=1",
|
||||
"style=filled",
|
||||
"fixedsize=false",
|
||||
'label="{:s}"'.format(compat_str('\n'.join(label))),
|
||||
]
|
||||
|
||||
if bone.name.startswith('ORG'):
|
||||
opts.append("fillcolor=yellow")
|
||||
else:
|
||||
opts.append("fillcolor=white")
|
||||
|
||||
fw('"{:s}" [{:s}];\n'.format(bone.name, ','.join(opts)))
|
||||
|
||||
fw('\n\n# Hierarchy:\n')
|
||||
|
||||
# Root node.
|
||||
if FAKE_PARENT:
|
||||
fw('"Object::{:s}" [];\n'.format(obj.name))
|
||||
|
||||
for bone in bones:
|
||||
bone = arm.bones[bone]
|
||||
|
||||
parent = bone.parent
|
||||
if parent:
|
||||
parent_name = parent.name
|
||||
connected = bone.use_connect
|
||||
elif FAKE_PARENT:
|
||||
parent_name = "Object::{:s}".format(obj.name)
|
||||
connected = False
|
||||
else:
|
||||
continue
|
||||
|
||||
opts = ["dir=forward", "weight=2", "arrowhead=normal"]
|
||||
if not connected:
|
||||
opts.append("style=dotted")
|
||||
|
||||
fw('"{:s}" -> "{:s}" [{:s}] ;\n'.format(bone.name, parent_name, ','.join(opts)))
|
||||
del bone
|
||||
|
||||
# constraints
|
||||
if CONSTRAINTS:
|
||||
fw('\n\n# Constraints:\n')
|
||||
for bone in bones:
|
||||
pbone = obj.pose.bones[bone]
|
||||
# must be ordered
|
||||
for constraint in pbone.constraints:
|
||||
subtarget = getattr(constraint, "subtarget", "")
|
||||
if subtarget:
|
||||
# TODO, not internal links
|
||||
opts = [
|
||||
'dir=forward',
|
||||
"weight=1",
|
||||
"arrowhead=normal",
|
||||
"arrowtail=none",
|
||||
"constraint=false",
|
||||
'color="red"',
|
||||
'labelfontsize=4',
|
||||
]
|
||||
if XTRA_INFO:
|
||||
label = "{:s}\n{:s}".format(constraint.type, constraint.name)
|
||||
opts.append('label="{:s}"'.format(compat_str(label)))
|
||||
fw('"{:s}" -> "{:s}" [{:s}] ;\n'.format(pbone.name, subtarget, ','.join(opts)))
|
||||
|
||||
# Drivers
|
||||
if DRIVERS:
|
||||
fw('\n\n# Drivers:\n')
|
||||
|
||||
def rna_path_as_pbone(rna_path):
|
||||
if not rna_path.startswith("pose.bones["):
|
||||
return None
|
||||
|
||||
# rna_path_bone = rna_path[:rna_path.index("]") + 1]
|
||||
# return obj.path_resolve(rna_path_bone)
|
||||
bone_name = rna_path.split("[")[1].split("]")[0]
|
||||
return obj.pose.bones[bone_name[1:-1]]
|
||||
|
||||
animation_data = obj.animation_data
|
||||
if animation_data:
|
||||
|
||||
fcurve_drivers = [fcurve_driver for fcurve_driver in animation_data.drivers]
|
||||
fcurve_drivers.sort(key=lambda fcurve_driver: fcurve_driver.data_path)
|
||||
|
||||
for fcurve_driver in fcurve_drivers:
|
||||
rna_path = fcurve_driver.data_path
|
||||
pbone = rna_path_as_pbone(rna_path)
|
||||
|
||||
if pbone:
|
||||
for var in fcurve_driver.driver.variables:
|
||||
for target in var.targets:
|
||||
pbone_target = rna_path_as_pbone(target.data_path)
|
||||
rna_path_target = target.data_path
|
||||
if pbone_target:
|
||||
opts = [
|
||||
'dir=forward',
|
||||
"weight=1",
|
||||
"arrowhead=normal",
|
||||
"arrowtail=none",
|
||||
"constraint=false",
|
||||
'color="blue"',
|
||||
"labelfontsize=4",
|
||||
]
|
||||
display_source = rna_path.replace("pose.bones", "")
|
||||
display_target = rna_path_target.replace("pose.bones", "")
|
||||
if XTRA_INFO:
|
||||
label = "{:s}\\n{:s}".format(display_source, display_target)
|
||||
opts.append('label="{:s}"'.format(compat_str(label)))
|
||||
fw('"{:s}" -> "{:s}" [{:s}] ;\n'.format(pbone_target.name, pbone.name, ','.join(opts)))
|
||||
|
||||
fw(footer)
|
||||
fileobject.close()
|
||||
|
||||
'''
|
||||
print(".", end="")
|
||||
import sys
|
||||
sys.stdout.flush()
|
||||
'''
|
||||
print("\nSaved:", filepath)
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
tmppath = "/tmp/test.dot"
|
||||
graph_armature(bpy.context.object, tmppath, CONSTRAINTS=True, DRIVERS=True)
|
||||
os.system("dot -Tpng {:s} > {:s}; eog {:s} &".format(tmppath, tmppath + '.png', tmppath + '.png'))
|
||||
352
blender-5.2.0/scripts/modules/_keyingsets_utils.py
Normal file
352
blender-5.2.0/scripts/modules/_keyingsets_utils.py
Normal file
@@ -0,0 +1,352 @@
|
||||
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# This file defines a set of methods that are useful for various
|
||||
# Relative Keying Set (RKS) related operations, such as: callbacks
|
||||
# for polling, iterator callbacks, and also generate callbacks.
|
||||
# All of these can be used in conjunction with the others.
|
||||
|
||||
__all__ = (
|
||||
"path_add_property",
|
||||
"RKS_POLL_selected_objects",
|
||||
"RKS_POLL_selected_bones",
|
||||
"RKS_POLL_selected_items",
|
||||
"RKS_ITER_selected_objects",
|
||||
"RKS_ITER_selected_bones",
|
||||
"RKS_ITER_selected_item",
|
||||
"RKS_GEN_available",
|
||||
"RKS_GEN_custom_props",
|
||||
"RKS_GEN_location",
|
||||
"RKS_GEN_rotation",
|
||||
"RKS_GEN_scaling",
|
||||
"RKS_GEN_bendy_bones",
|
||||
)
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
###########################
|
||||
# General Utilities
|
||||
|
||||
|
||||
# Append the specified property name on the existing path.
|
||||
def path_add_property(path, prop):
|
||||
if path:
|
||||
return path + "." + prop
|
||||
else:
|
||||
return prop
|
||||
|
||||
###########################
|
||||
# Poll Callbacks
|
||||
|
||||
|
||||
# selected objects (active object must be in object mode)
|
||||
def RKS_POLL_selected_objects(_ksi, context):
|
||||
if context.area.type == 'SEQUENCE_EDITOR':
|
||||
return False
|
||||
|
||||
ob = context.active_object
|
||||
if ob:
|
||||
return ob.mode == 'OBJECT'
|
||||
else:
|
||||
return bool(context.selected_objects)
|
||||
|
||||
|
||||
# selected bones
|
||||
def RKS_POLL_selected_bones(_ksi, context):
|
||||
if context.area.type == 'SEQUENCE_EDITOR':
|
||||
return False
|
||||
|
||||
# we must be in Pose Mode, and there must be some bones selected
|
||||
ob = context.active_object
|
||||
if ob and ob.mode == 'POSE':
|
||||
if context.active_pose_bone or context.selected_pose_bones:
|
||||
return True
|
||||
|
||||
# nothing selected
|
||||
return False
|
||||
|
||||
# Selected sequencer strip.
|
||||
|
||||
|
||||
def RKS_POLL_selected_strip(_ksi, context):
|
||||
if context.active_strip or context.selected_strips:
|
||||
return True
|
||||
|
||||
# nothing selected
|
||||
return False
|
||||
|
||||
|
||||
# selected bones, objects or strips
|
||||
def RKS_POLL_selected_items(ksi, context):
|
||||
return (RKS_POLL_selected_bones(ksi, context) or
|
||||
RKS_POLL_selected_objects(ksi, context) or
|
||||
RKS_POLL_selected_strip(ksi, context))
|
||||
|
||||
# selected bones or objects
|
||||
|
||||
|
||||
def RKS_POLL_selected_bones_or_objects(ksi, context):
|
||||
return (RKS_POLL_selected_bones(ksi, context) or
|
||||
RKS_POLL_selected_objects(ksi, context))
|
||||
|
||||
###########################
|
||||
# Iterator Callbacks
|
||||
|
||||
|
||||
# All selected objects or pose bones, depending on which we've got.
|
||||
def RKS_ITER_selected_item(ksi, context, ks):
|
||||
if context.area.type == 'SEQUENCE_EDITOR':
|
||||
if context.selected_strips:
|
||||
for strip in context.selected_strips:
|
||||
ksi.generate(context, ks, strip)
|
||||
return
|
||||
|
||||
ob = context.active_object
|
||||
if ob and ob.mode == 'POSE':
|
||||
for bone in context.selected_pose_bones:
|
||||
ksi.generate(context, ks, bone)
|
||||
elif context.selected_objects:
|
||||
for ob in context.selected_objects:
|
||||
ksi.generate(context, ks, ob)
|
||||
|
||||
|
||||
# All selected objects only.
|
||||
def RKS_ITER_selected_objects(ksi, context, ks):
|
||||
for ob in context.selected_objects:
|
||||
ksi.generate(context, ks, ob)
|
||||
|
||||
|
||||
# All selected bones only.
|
||||
def RKS_ITER_selected_bones(ksi, context, ks):
|
||||
for bone in context.selected_pose_bones:
|
||||
ksi.generate(context, ks, bone)
|
||||
|
||||
###########################
|
||||
# Generate Callbacks
|
||||
|
||||
|
||||
# "Available" F-Curves.
|
||||
def RKS_GEN_available(_ksi, _context, ks, data):
|
||||
from bpy_extras import anim_utils
|
||||
|
||||
# try to get the animation data associated with the closest
|
||||
# ID-block to the data (neither of which may exist/be easy to find)
|
||||
id_block = data.id_data
|
||||
adt = getattr(id_block, "animation_data", None)
|
||||
|
||||
# there must also be an active action...
|
||||
if adt is None or adt.action is None:
|
||||
return
|
||||
|
||||
# if we haven't got an ID-block as 'data', try to restrict
|
||||
# paths added to only those which branch off from here
|
||||
# i.e. for bones
|
||||
if id_block != data:
|
||||
basePath = data.path_from_id()
|
||||
else:
|
||||
basePath = None # this is not needed...
|
||||
|
||||
# for each F-Curve, include a path to key it
|
||||
# NOTE: we don't need to set the group settings here
|
||||
cbag = anim_utils.action_get_channelbag_for_slot(adt.action, adt.action_slot)
|
||||
if not cbag:
|
||||
return
|
||||
|
||||
for fcu in cbag.fcurves:
|
||||
if basePath:
|
||||
if basePath in fcu.data_path:
|
||||
ks.paths.add(id_block, fcu.data_path, index=fcu.array_index)
|
||||
else:
|
||||
ks.paths.add(id_block, fcu.data_path, index=fcu.array_index)
|
||||
|
||||
# ------
|
||||
|
||||
|
||||
# get ID block and based ID path for transform generators
|
||||
# private function
|
||||
def get_transform_generators_base_info(data):
|
||||
# ID-block for the data
|
||||
id_block = data.id_data
|
||||
|
||||
# get base path and grouping method/name
|
||||
if isinstance(data, bpy.types.ID):
|
||||
# no path in this case
|
||||
path = ""
|
||||
|
||||
# Transform data on ID-blocks directly should get grouped under a
|
||||
# hard-coded label ("Object Transforms") so that they get grouped
|
||||
# consistently when key-framed directly.
|
||||
grouping = "Object Transforms"
|
||||
else:
|
||||
# get the path to the ID-block
|
||||
path = data.path_from_id()
|
||||
|
||||
# try to use the name of the data element to group the F-Curve
|
||||
# else fall back on the KeyingSet name
|
||||
grouping = getattr(data, "name", None)
|
||||
|
||||
# return the ID-block and the path
|
||||
return id_block, path, grouping
|
||||
|
||||
|
||||
# Location
|
||||
def RKS_GEN_location(_ksi, _context, ks, data):
|
||||
# get id-block and path info
|
||||
id_block, base_path, grouping = get_transform_generators_base_info(data)
|
||||
|
||||
if isinstance(data, bpy.types.Strip):
|
||||
path_x = path_add_property(base_path, "transform.offset_x")
|
||||
path_y = path_add_property(base_path, "transform.offset_y")
|
||||
if grouping:
|
||||
ks.paths.add(id_block, path_x, group_method='NAMED', group_name=grouping)
|
||||
ks.paths.add(id_block, path_y, group_method='NAMED', group_name=grouping)
|
||||
else:
|
||||
ks.paths.add(id_block, path_x)
|
||||
ks.paths.add(id_block, path_y)
|
||||
return
|
||||
|
||||
# add the property name to the base path
|
||||
path = path_add_property(base_path, "location")
|
||||
|
||||
# add Keying Set entry for this...
|
||||
if grouping:
|
||||
ks.paths.add(id_block, path, group_method='NAMED', group_name=grouping)
|
||||
else:
|
||||
ks.paths.add(id_block, path)
|
||||
|
||||
|
||||
# Rotation
|
||||
def RKS_GEN_rotation(_ksi, _context, ks, data):
|
||||
# get id-block and path info
|
||||
id_block, base_path, grouping = get_transform_generators_base_info(data)
|
||||
|
||||
# add the property name to the base path
|
||||
if isinstance(data, bpy.types.Strip):
|
||||
path = path_add_property(base_path, "transform.rotation")
|
||||
if grouping:
|
||||
ks.paths.add(id_block, path, group_method='NAMED', group_name=grouping)
|
||||
else:
|
||||
ks.paths.add(id_block, path)
|
||||
return
|
||||
# rotation mode affects the property used
|
||||
if data.rotation_mode == 'QUATERNION':
|
||||
path = path_add_property(base_path, "rotation_quaternion")
|
||||
elif data.rotation_mode == 'AXIS_ANGLE':
|
||||
path = path_add_property(base_path, "rotation_axis_angle")
|
||||
else:
|
||||
path = path_add_property(base_path, "rotation_euler")
|
||||
|
||||
# add Keying Set entry for this...
|
||||
if grouping:
|
||||
ks.paths.add(id_block, path, group_method='NAMED', group_name=grouping)
|
||||
else:
|
||||
ks.paths.add(id_block, path)
|
||||
|
||||
|
||||
# Scaling
|
||||
def RKS_GEN_scaling(_ksi, _context, ks, data):
|
||||
# get id-block and path info
|
||||
id_block, base_path, grouping = get_transform_generators_base_info(data)
|
||||
|
||||
if isinstance(data, bpy.types.Strip):
|
||||
path_x = path_add_property(base_path, "transform.scale_x")
|
||||
path_y = path_add_property(base_path, "transform.scale_y")
|
||||
if grouping:
|
||||
ks.paths.add(id_block, path_x, group_method='NAMED', group_name=grouping)
|
||||
ks.paths.add(id_block, path_y, group_method='NAMED', group_name=grouping)
|
||||
else:
|
||||
ks.paths.add(id_block, path_x)
|
||||
ks.paths.add(id_block, path_y)
|
||||
return
|
||||
# add the property name to the base path
|
||||
path = path_add_property(base_path, "scale")
|
||||
|
||||
# add Keying Set entry for this...
|
||||
if grouping:
|
||||
ks.paths.add(id_block, path, group_method='NAMED', group_name=grouping)
|
||||
else:
|
||||
ks.paths.add(id_block, path)
|
||||
|
||||
|
||||
# Custom Properties
|
||||
def RKS_GEN_custom_props(_ksi, _context, ks, data):
|
||||
# get id-block and path info
|
||||
id_block, base_path, grouping = get_transform_generators_base_info(data)
|
||||
|
||||
# Only some RNA types can be animated.
|
||||
prop_type_compat = {bpy.types.BoolProperty,
|
||||
bpy.types.IntProperty,
|
||||
bpy.types.FloatProperty,
|
||||
bpy.types.EnumProperty}
|
||||
|
||||
# When working with a pose, 'id_block' is the armature object (which should
|
||||
# get the animation data), whereas 'data' is the bone being keyed.
|
||||
for cprop_name in data.keys():
|
||||
# ignore special "_RNA_UI" used for UI editing
|
||||
if cprop_name == "_RNA_UI":
|
||||
continue
|
||||
if cprop_name in data.bl_rna.properties and not data.bl_rna.properties[cprop_name].is_animatable:
|
||||
continue
|
||||
|
||||
if cprop_name in data.bl_rna.properties:
|
||||
prop_path = cprop_name
|
||||
else:
|
||||
prop_path = '["{:s}"]'.format(bpy.utils.escape_identifier(cprop_name))
|
||||
|
||||
try:
|
||||
rna_property = data.path_resolve(prop_path, False)
|
||||
except ValueError:
|
||||
# Can technically happen, but there is no known case.
|
||||
continue
|
||||
if rna_property is None:
|
||||
# In this case the property cannot be converted to an
|
||||
# FCurve-compatible value, so we can't keyframe it anyways.
|
||||
continue
|
||||
if rna_property.rna_type not in prop_type_compat:
|
||||
continue
|
||||
|
||||
path = "{:s}{:s}".format(base_path, prop_path)
|
||||
if grouping:
|
||||
ks.paths.add(id_block, path, group_method='NAMED', group_name=grouping)
|
||||
else:
|
||||
ks.paths.add(id_block, path)
|
||||
|
||||
# ------
|
||||
|
||||
|
||||
# Property identifiers for Bendy Bones
|
||||
bbone_property_ids = (
|
||||
"bbone_curveinx",
|
||||
"bbone_curveinz",
|
||||
"bbone_curveoutx",
|
||||
"bbone_curveoutz",
|
||||
|
||||
"bbone_rollin",
|
||||
"bbone_rollout",
|
||||
|
||||
"bbone_scalein",
|
||||
"bbone_scaleout",
|
||||
|
||||
"bbone_easein",
|
||||
"bbone_easeout",
|
||||
)
|
||||
|
||||
|
||||
# Add Keying Set entries for bendy bones
|
||||
def RKS_GEN_bendy_bones(_ksi, _context, ks, data):
|
||||
# get id-block and path info
|
||||
# NOTE: This assumes that we're dealing with a bone here...
|
||||
id_block, base_path, grouping = get_transform_generators_base_info(data)
|
||||
|
||||
# for each of the bendy bone properties, add a Keying Set entry for it...
|
||||
for propname in bbone_property_ids:
|
||||
# add the property name to the base path
|
||||
path = path_add_property(base_path, propname)
|
||||
|
||||
# add Keying Set entry for this...
|
||||
if grouping:
|
||||
ks.paths.add(id_block, path, group_method='NAMED', group_name=grouping)
|
||||
else:
|
||||
ks.paths.add(id_block, path)
|
||||
1078
blender-5.2.0/scripts/modules/_rna_info.py
Normal file
1078
blender-5.2.0/scripts/modules/_rna_info.py
Normal file
File diff suppressed because it is too large
Load Diff
4687
blender-5.2.0/scripts/modules/_rna_manual_reference.py
Normal file
4687
blender-5.2.0/scripts/modules/_rna_manual_reference.py
Normal file
File diff suppressed because it is too large
Load Diff
422
blender-5.2.0/scripts/modules/_rna_xml.py
Normal file
422
blender-5.2.0/scripts/modules/_rna_xml.py
Normal file
@@ -0,0 +1,422 @@
|
||||
# SPDX-FileCopyrightText: 2012-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"xml_file_run",
|
||||
"xml_file_write",
|
||||
)
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def build_property_typemap(skip_classes, skip_typemap):
|
||||
|
||||
property_typemap = {}
|
||||
|
||||
for attr in dir(bpy.types):
|
||||
# Skip internal methods.
|
||||
if attr.startswith("_"):
|
||||
continue
|
||||
cls = getattr(bpy.types, attr)
|
||||
if issubclass(cls, skip_classes):
|
||||
continue
|
||||
bl_rna = getattr(cls, "bl_rna", None)
|
||||
# Needed to skip classes added to the modules `__dict__`.
|
||||
if bl_rna is None:
|
||||
continue
|
||||
|
||||
# # to support skip-save we can't get all props
|
||||
# properties = bl_rna.properties.keys()
|
||||
properties = []
|
||||
for prop_id, prop in bl_rna.properties.items():
|
||||
if not prop.is_skip_save:
|
||||
properties.append(prop_id)
|
||||
|
||||
properties.remove("rna_type")
|
||||
property_typemap[attr] = properties
|
||||
|
||||
if skip_typemap:
|
||||
for cls_name, properties_blacklist in skip_typemap.items():
|
||||
properties = property_typemap.get(cls_name)
|
||||
if properties is not None:
|
||||
for prop_id in properties_blacklist:
|
||||
try:
|
||||
properties.remove(prop_id)
|
||||
except Exception:
|
||||
print("skip_typemap unknown prop_id '{:s}.{:s}'".format(cls_name, prop_id))
|
||||
else:
|
||||
print("skip_typemap unknown class '{:s}'".format(cls_name))
|
||||
|
||||
return property_typemap
|
||||
|
||||
|
||||
def print_ln(data):
|
||||
print(data, end="")
|
||||
|
||||
|
||||
def rna2xml(
|
||||
fw=print_ln,
|
||||
root_node="",
|
||||
root_rna=None, # must be set
|
||||
root_rna_skip=set(),
|
||||
root_ident="",
|
||||
ident_val=" ",
|
||||
skip_classes=(
|
||||
bpy.types.Operator,
|
||||
bpy.types.Panel,
|
||||
bpy.types.KeyingSet,
|
||||
bpy.types.Header,
|
||||
bpy.types.PropertyGroup,
|
||||
),
|
||||
skip_typemap=None,
|
||||
pretty_format=True,
|
||||
method='DATA',
|
||||
):
|
||||
from xml.sax.saxutils import quoteattr
|
||||
property_typemap = build_property_typemap(skip_classes, skip_typemap)
|
||||
|
||||
# don't follow properties of this type, just reference them by name
|
||||
# they MUST have a unique 'name' property.
|
||||
# 'ID' covers most types
|
||||
referenced_classes = (
|
||||
bpy.types.ID,
|
||||
bpy.types.Bone,
|
||||
bpy.types.ActionGroup,
|
||||
bpy.types.PoseBone,
|
||||
bpy.types.Node,
|
||||
bpy.types.Strip,
|
||||
)
|
||||
|
||||
def number_to_str(val, val_type):
|
||||
if val_type == int:
|
||||
return "{:d}".format(val)
|
||||
elif val_type == float:
|
||||
return "{:.6g}".format(val)
|
||||
elif val_type == bool:
|
||||
return "TRUE" if val else "FALSE"
|
||||
else:
|
||||
raise NotImplementedError("this type is not a number {:s}".format(val_type))
|
||||
|
||||
def rna2xml_node(ident, value, parent):
|
||||
ident_next = ident + ident_val
|
||||
|
||||
# divide into attrs and nodes.
|
||||
node_attrs = []
|
||||
nodes_items = []
|
||||
nodes_lists = []
|
||||
|
||||
value_type = type(value)
|
||||
|
||||
if issubclass(value_type, skip_classes):
|
||||
return
|
||||
|
||||
# XXX, FIXME, point-cache has eternal nested pointer to itself.
|
||||
if value == parent:
|
||||
return
|
||||
|
||||
value_type_name = value_type.__name__
|
||||
for prop in property_typemap[value_type_name]:
|
||||
|
||||
subvalue = getattr(value, prop)
|
||||
subvalue_type = type(subvalue)
|
||||
|
||||
if subvalue_type in {int, bool, float}:
|
||||
node_attrs.append("{:s}=\"{:s}\"".format(prop, number_to_str(subvalue, subvalue_type)))
|
||||
elif subvalue_type is str:
|
||||
node_attrs.append("{:s}={:s}".format(prop, quoteattr(subvalue)))
|
||||
elif subvalue_type is set:
|
||||
node_attrs.append("{:s}={:s}".format(prop, quoteattr("{" + ",".join(list(subvalue)) + "}")))
|
||||
elif subvalue is None:
|
||||
node_attrs.append("{:s}=\"NONE\"".format(prop))
|
||||
elif issubclass(subvalue_type, referenced_classes):
|
||||
# special case, ID's are always referenced.
|
||||
node_attrs.append("{:s}={:s}".format(prop, quoteattr(subvalue_type.__name__ + "::" + subvalue.name)))
|
||||
else:
|
||||
try:
|
||||
subvalue_ls = list(subvalue)
|
||||
except Exception:
|
||||
subvalue_ls = None
|
||||
|
||||
if subvalue_ls is None:
|
||||
nodes_items.append((prop, subvalue, subvalue_type))
|
||||
else:
|
||||
# check if the list contains native types
|
||||
subvalue_rna = value.path_resolve(prop, False)
|
||||
if type(subvalue_rna).__name__ == "bpy_prop_array":
|
||||
# Check if this is a 0-1 color (RGB, RGBA)
|
||||
# in that case write as a hexadecimal.
|
||||
prop_rna = value.bl_rna.properties[prop]
|
||||
if (prop_rna.subtype == 'COLOR_GAMMA' and
|
||||
prop_rna.hard_min == 0.0 and
|
||||
prop_rna.hard_max == 1.0 and
|
||||
prop_rna.array_length in {3, 4}):
|
||||
# -----
|
||||
# color
|
||||
array_value = "#" + "".join(("{:02x}".format(int(v * 255)) for v in subvalue_rna))
|
||||
|
||||
else:
|
||||
# default
|
||||
def str_recursive(s):
|
||||
subsubvalue_type = type(s)
|
||||
if subsubvalue_type in {int, float, bool}:
|
||||
return number_to_str(s, subsubvalue_type)
|
||||
else:
|
||||
return " ".join([str_recursive(si) for si in s])
|
||||
|
||||
array_value = " ".join(str_recursive(v) for v in subvalue_rna)
|
||||
|
||||
node_attrs.append("{:s}=\"{:s}\"".format(prop, array_value))
|
||||
else:
|
||||
nodes_lists.append((prop, subvalue_ls, subvalue_type))
|
||||
|
||||
# declare + attributes
|
||||
if pretty_format:
|
||||
if node_attrs:
|
||||
fw("{:s}<{:s}\n".format(ident, value_type_name))
|
||||
for node_attr in node_attrs:
|
||||
fw("{:s}{:s}\n".format(ident_next, node_attr))
|
||||
fw("{:s}>\n".format(ident_next,))
|
||||
else:
|
||||
fw("{:s}<{:s}>\n".format(ident, value_type_name))
|
||||
else:
|
||||
fw("{:s}<{:s} {:s}>\n".format(ident, value_type_name, " ".join(node_attrs)))
|
||||
|
||||
# unique members
|
||||
for prop, subvalue, subvalue_type in nodes_items:
|
||||
fw("{:s}<{:s}>\n".format(ident_next, prop)) # XXX, this is awkward, how best to solve?
|
||||
rna2xml_node(ident_next + ident_val, subvalue, value)
|
||||
fw("{:s}</{:s}>\n".format(ident_next, prop)) # XXX, need to check on this.
|
||||
|
||||
# list members
|
||||
for prop, subvalue, subvalue_type in nodes_lists:
|
||||
fw("{:s}<{:s}>\n".format(ident_next, prop))
|
||||
for subvalue_item in subvalue:
|
||||
if subvalue_item is not None:
|
||||
rna2xml_node(ident_next + ident_val, subvalue_item, value)
|
||||
fw("{:s}</{:s}>\n".format(ident_next, prop))
|
||||
|
||||
fw("{:s}</{:s}>\n".format(ident, value_type_name))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# needs re-working to be generic
|
||||
|
||||
if root_node:
|
||||
fw("{:s}<{:s}>\n".format(root_ident, root_node))
|
||||
|
||||
# bpy.data
|
||||
if method == 'DATA':
|
||||
ident = root_ident + ident_val
|
||||
for attr in dir(root_rna):
|
||||
|
||||
# exceptions
|
||||
if attr.startswith("_"):
|
||||
continue
|
||||
elif attr in root_rna_skip:
|
||||
continue
|
||||
|
||||
value = getattr(root_rna, attr)
|
||||
try:
|
||||
ls = value[:]
|
||||
except Exception:
|
||||
ls = None
|
||||
|
||||
if type(ls) == list:
|
||||
fw("{:s}<{:s}>\n".format(ident, attr))
|
||||
for blend_id in ls:
|
||||
rna2xml_node(ident + ident_val, blend_id, None)
|
||||
fw("{:s}</{:s}>\n".format(ident_val, attr))
|
||||
# any attribute
|
||||
elif method == 'ATTR':
|
||||
rna2xml_node(root_ident, root_rna, None)
|
||||
|
||||
if root_node:
|
||||
fw("{:s}</{:s}>\n".format(root_ident, root_node))
|
||||
|
||||
|
||||
# NOTE(@ideasman42): regarding `secure_types`.
|
||||
# This is a safe guard when loading an untrusted XML to prevent any possibility of the XML
|
||||
# paths "escaping" the intended data types, potentially writing into unexpected settings.
|
||||
# This is done because the XML itself defines the attributes which are recursed into,
|
||||
# there is a possibility the XML recurse into data that isn't logically owned by "root",
|
||||
# out of the theme and into user preferences for example, which could change trust settings
|
||||
# even executing code.
|
||||
#
|
||||
# At the time of writing it seems this is not possible with themes (the main user of this functionality),
|
||||
# however this could become possible in the future through additional RNA properties and it wouldn't be
|
||||
# obvious an exploit existed.
|
||||
#
|
||||
# In short, it's safest for users of this API to restrict types when loading untrusted XML.
|
||||
|
||||
def xml2rna(
|
||||
root_xml, *,
|
||||
root_rna=None, # must be set
|
||||
secure_types=None, # `Set[str] | None`
|
||||
):
|
||||
|
||||
def xml2rna_node(xml_node, value):
|
||||
# print("evaluating:", xml_node.nodeName)
|
||||
|
||||
if (secure_types is not None) and (xml_node.nodeName not in secure_types):
|
||||
print("Loading the XML with type restrictions, skipping \"{:s}\"".format(xml_node.nodeName))
|
||||
return
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Simple attributes
|
||||
|
||||
for attr in xml_node.attributes.keys():
|
||||
# print(" ", attr)
|
||||
subvalue = getattr(value, attr, Ellipsis)
|
||||
|
||||
if subvalue is Ellipsis:
|
||||
print("{:s}.{:s} not found".format(type(value).__name__, attr))
|
||||
else:
|
||||
value_xml = xml_node.attributes[attr].value
|
||||
|
||||
subvalue_type = type(subvalue)
|
||||
# tp_name = 'UNKNOWN'
|
||||
if subvalue_type == float:
|
||||
value_xml_coerce = float(value_xml)
|
||||
# tp_name = 'FLOAT'
|
||||
elif subvalue_type == int:
|
||||
value_xml_coerce = int(value_xml)
|
||||
# tp_name = 'INT'
|
||||
elif subvalue_type == bool:
|
||||
value_xml_coerce = {'TRUE': True, 'FALSE': False}[value_xml]
|
||||
# tp_name = 'BOOL'
|
||||
elif subvalue_type == str:
|
||||
value_xml_coerce = value_xml
|
||||
# tp_name = 'STR'
|
||||
elif hasattr(subvalue, "__len__"):
|
||||
if value_xml.startswith("#"):
|
||||
# read hexadecimal value as float array
|
||||
value_xml_split = value_xml[1:]
|
||||
value_xml_coerce = [int(value_xml_split[i:i + 2], 16) /
|
||||
255 for i in range(0, len(value_xml_split), 2)]
|
||||
del value_xml_split
|
||||
else:
|
||||
value_xml_split = value_xml.split()
|
||||
try:
|
||||
value_xml_coerce = [int(v) for v in value_xml_split]
|
||||
except ValueError:
|
||||
try:
|
||||
value_xml_coerce = [float(v) for v in value_xml_split]
|
||||
except ValueError: # bool vector property
|
||||
value_xml_coerce = [{'TRUE': True, 'FALSE': False}[v] for v in value_xml_split]
|
||||
del value_xml_split
|
||||
# tp_name = 'ARRAY'
|
||||
|
||||
# print(" {:s}.{:s} ({:s}) --- {:s}".format(type(value).__name__, attr, tp_name, subvalue_type))
|
||||
try:
|
||||
setattr(value, attr, value_xml_coerce)
|
||||
except ValueError:
|
||||
# size mismatch
|
||||
val = getattr(value, attr)
|
||||
if len(val) < len(value_xml_coerce):
|
||||
setattr(value, attr, value_xml_coerce[:len(val)])
|
||||
else:
|
||||
setattr(value, attr, list(value_xml_coerce) + list(val)[len(value_xml_coerce):])
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Complex attributes
|
||||
for child_xml in xml_node.childNodes:
|
||||
if child_xml.nodeType == child_xml.ELEMENT_NODE:
|
||||
# print()
|
||||
# print(child_xml.nodeName)
|
||||
subvalue = getattr(value, child_xml.nodeName, None)
|
||||
if subvalue is not None:
|
||||
|
||||
elems = []
|
||||
for child_xml_real in child_xml.childNodes:
|
||||
if child_xml_real.nodeType == child_xml_real.ELEMENT_NODE:
|
||||
elems.append(child_xml_real)
|
||||
del child_xml_real
|
||||
|
||||
if hasattr(subvalue, "__len__"):
|
||||
# Collection
|
||||
if len(elems) != len(subvalue):
|
||||
print("Size Mismatch! collection:", child_xml.nodeName)
|
||||
else:
|
||||
for i in range(len(elems)):
|
||||
child_xml_real = elems[i]
|
||||
subsubvalue = subvalue[i]
|
||||
|
||||
if child_xml_real is None or subsubvalue is None:
|
||||
print("None found {:s} - {:d} collection:".format(child_xml.nodeName, i))
|
||||
else:
|
||||
xml2rna_node(child_xml_real, subsubvalue)
|
||||
|
||||
else:
|
||||
# print(elems)
|
||||
if len(elems) == 1:
|
||||
# sub node named by its type
|
||||
child_xml_real = elems[0]
|
||||
|
||||
# print(child_xml_real, subvalue)
|
||||
xml2rna_node(child_xml_real, subvalue)
|
||||
else:
|
||||
# empty is valid too
|
||||
pass
|
||||
|
||||
xml2rna_node(root_xml, root_rna)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utility function used by presets.
|
||||
# The idea is you can run a preset like a script with a few args.
|
||||
#
|
||||
# This roughly matches the operator 'bpy.ops.script.python_file_run'
|
||||
|
||||
|
||||
def _get_context_val(context, path):
|
||||
try:
|
||||
value = context.path_resolve(path)
|
||||
except Exception as ex:
|
||||
print("Error: {!r}, path {!r} not found".format(ex, path))
|
||||
value = Ellipsis
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def xml_file_run(
|
||||
context,
|
||||
filepath,
|
||||
rna_map,
|
||||
secure_types=None, # `set[str] | None`
|
||||
):
|
||||
import xml.dom.minidom
|
||||
|
||||
xml_nodes = xml.dom.minidom.parse(filepath)
|
||||
bpy_xml = xml_nodes.getElementsByTagName("bpy")[0]
|
||||
|
||||
for rna_path, xml_tag in rna_map:
|
||||
|
||||
# first get xml
|
||||
# TODO, error check
|
||||
xml_node = bpy_xml.getElementsByTagName(xml_tag)[0]
|
||||
|
||||
value = _get_context_val(context, rna_path)
|
||||
|
||||
if value is not Ellipsis and value is not None:
|
||||
# print(" loading XML: {!r} -> {!r}".format(filepath, rna_path))
|
||||
xml2rna(xml_node, root_rna=value, secure_types=secure_types)
|
||||
|
||||
|
||||
def xml_file_write(context, filepath, rna_map, *, skip_typemap=None):
|
||||
with open(filepath, "w", encoding="utf-8") as file:
|
||||
fw = file.write
|
||||
fw("<bpy>\n")
|
||||
|
||||
for rna_path, _xml_tag in rna_map:
|
||||
# xml_tag is ignored, we get this from the rna
|
||||
value = _get_context_val(context, rna_path)
|
||||
rna2xml(
|
||||
fw=fw,
|
||||
root_rna=value,
|
||||
method='ATTR',
|
||||
root_ident=" ",
|
||||
ident_val=" ",
|
||||
skip_typemap=skip_typemap,
|
||||
)
|
||||
|
||||
fw("</bpy>\n")
|
||||
2011
blender-5.2.0/scripts/modules/addon_utils.py
Normal file
2011
blender-5.2.0/scripts/modules/addon_utils.py
Normal file
File diff suppressed because it is too large
Load Diff
211
blender-5.2.0/scripts/modules/bl_app_override/__init__.py
Normal file
211
blender-5.2.0/scripts/modules/bl_app_override/__init__.py
Normal file
@@ -0,0 +1,211 @@
|
||||
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
Module to manage overriding various parts of Blender.
|
||||
|
||||
Intended for use with 'app_templates', though it can be used from anywhere.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"class_filter",
|
||||
"ui_draw_filter_register",
|
||||
"ui_draw_filter_unregister",
|
||||
)
|
||||
|
||||
|
||||
# TODO, how to check these aren't from add-ons.
|
||||
# templates might need to un-register while filtering.
|
||||
def class_filter(cls_parent, **kw):
|
||||
whitelist = kw.pop("whitelist", None)
|
||||
blacklist = kw.pop("blacklist", None)
|
||||
kw_items = tuple(kw.items())
|
||||
for cls in cls_parent.__subclasses__():
|
||||
# same as is_registered()
|
||||
if "bl_rna" in cls.__dict__:
|
||||
if blacklist is not None and cls.__name__ in blacklist:
|
||||
continue
|
||||
if ((whitelist is not None and cls.__name__ is whitelist) or
|
||||
all((getattr(cls, attr) in expect) for attr, expect in kw_items)):
|
||||
yield cls
|
||||
|
||||
|
||||
def ui_draw_filter_register(
|
||||
*,
|
||||
ui_ignore_classes=None,
|
||||
ui_ignore_operator=None,
|
||||
ui_ignore_property=None,
|
||||
ui_ignore_menu=None,
|
||||
ui_ignore_label=None,
|
||||
):
|
||||
import bpy
|
||||
|
||||
UILayout = bpy.types.UILayout
|
||||
|
||||
if ui_ignore_classes is None:
|
||||
ui_ignore_classes = (
|
||||
bpy.types.Panel,
|
||||
bpy.types.Menu,
|
||||
bpy.types.Header,
|
||||
)
|
||||
|
||||
class OperatorProperties_Fake:
|
||||
pass
|
||||
|
||||
class UILayout_Fake(bpy.types.UILayout):
|
||||
__slots__ = ()
|
||||
|
||||
def __getattribute__(self, attr):
|
||||
# ensure we always pass down UILayout_Fake instances
|
||||
if attr in {"row", "split", "column", "box", "column_flow"}:
|
||||
real_func = UILayout.__getattribute__(self, attr)
|
||||
|
||||
def dummy_func(*args, **kw):
|
||||
# print("wrapped", attr)
|
||||
ret = real_func(*args, **kw)
|
||||
return UILayout_Fake(ret)
|
||||
return dummy_func
|
||||
|
||||
elif attr in {"operator", "operator_menu_enum", "operator_enum", "operator_menu_hold"}:
|
||||
if ui_ignore_operator is None:
|
||||
return UILayout.__getattribute__(self, attr)
|
||||
|
||||
real_func = UILayout.__getattribute__(self, attr)
|
||||
|
||||
def dummy_func(*args, **kw):
|
||||
# print("wrapped", attr)
|
||||
ui_test = ui_ignore_operator(args[0])
|
||||
if ui_test is False:
|
||||
ret = real_func(*args, **kw)
|
||||
else:
|
||||
if ui_test is None:
|
||||
UILayout.__getattribute__(self, "label")(text="")
|
||||
else:
|
||||
assert ui_test is True
|
||||
# may need to be set
|
||||
ret = OperatorProperties_Fake()
|
||||
return ret
|
||||
return dummy_func
|
||||
|
||||
elif attr in {"prop", "prop_enum"}:
|
||||
if ui_ignore_property is None:
|
||||
return UILayout.__getattribute__(self, attr)
|
||||
|
||||
real_func = UILayout.__getattribute__(self, attr)
|
||||
|
||||
def dummy_func(*args, **kw):
|
||||
# print("wrapped", attr)
|
||||
ui_test = ui_ignore_property(args[0].__class__.__name__, args[1])
|
||||
if ui_test is False:
|
||||
ret = real_func(*args, **kw)
|
||||
else:
|
||||
if ui_test is None:
|
||||
UILayout.__getattribute__(self, "label")(text="")
|
||||
else:
|
||||
assert ui_test is True
|
||||
ret = None
|
||||
return ret
|
||||
return dummy_func
|
||||
|
||||
elif attr == "menu":
|
||||
if ui_ignore_menu is None:
|
||||
return UILayout.__getattribute__(self, attr)
|
||||
|
||||
real_func = UILayout.__getattribute__(self, attr)
|
||||
|
||||
def dummy_func(*args, **kw):
|
||||
# print("wrapped", attr)
|
||||
ui_test = ui_ignore_menu(args[0])
|
||||
if ui_test is False:
|
||||
ret = real_func(*args, **kw)
|
||||
else:
|
||||
if ui_test is None:
|
||||
UILayout.__getattribute__(self, "label")(text="")
|
||||
else:
|
||||
assert ui_test is True
|
||||
ret = None
|
||||
return ret
|
||||
return dummy_func
|
||||
|
||||
elif attr == "label":
|
||||
if ui_ignore_label is None:
|
||||
return UILayout.__getattribute__(self, attr)
|
||||
|
||||
real_func = UILayout.__getattribute__(self, attr)
|
||||
|
||||
def dummy_func(*args, **kw):
|
||||
# print("wrapped", attr)
|
||||
ui_test = ui_ignore_label(args[0] if args else kw.get("text", ""))
|
||||
if ui_test is False:
|
||||
ret = real_func(*args, **kw)
|
||||
else:
|
||||
if ui_test is None:
|
||||
real_func(text="")
|
||||
else:
|
||||
assert ui_test is True
|
||||
ret = None
|
||||
return ret
|
||||
return dummy_func
|
||||
else:
|
||||
return UILayout.__getattribute__(self, attr)
|
||||
# print(self, attr)
|
||||
|
||||
def operator(*args, **kw):
|
||||
return super().operator(*args, **kw)
|
||||
|
||||
def draw_override(func_orig, self_real, context):
|
||||
cls_real = self_real.__class__
|
||||
if cls_real is super:
|
||||
# simple, no wrapping
|
||||
return func_orig(self_real, context)
|
||||
|
||||
class Wrapper(cls_real):
|
||||
__slots__ = ()
|
||||
|
||||
def __getattribute__(self, attr):
|
||||
if attr == "layout":
|
||||
return UILayout_Fake(self_real.layout)
|
||||
else:
|
||||
cls = super()
|
||||
try:
|
||||
return cls.__getattr__(self, attr)
|
||||
except AttributeError:
|
||||
# class variable
|
||||
try:
|
||||
return getattr(cls, attr)
|
||||
except AttributeError:
|
||||
# for preset bl_idname access
|
||||
return getattr(UILayout(self), attr)
|
||||
|
||||
@property
|
||||
def layout(self):
|
||||
# print("wrapped")
|
||||
return self_real.layout
|
||||
|
||||
return func_orig(Wrapper(self_real), context)
|
||||
|
||||
ui_ignore_store = []
|
||||
|
||||
for cls in ui_ignore_classes:
|
||||
for subcls in list(cls.__subclasses__()):
|
||||
if "draw" in subcls.__dict__: # don't want to get parents draw()
|
||||
|
||||
def replace_draw():
|
||||
# function also serves to hold draw_old in a local name-space
|
||||
draw_orig = subcls.draw
|
||||
|
||||
def draw(self, context):
|
||||
return draw_override(draw_orig, self, context)
|
||||
subcls.draw = draw
|
||||
|
||||
ui_ignore_store.append((subcls, "draw", subcls.draw))
|
||||
|
||||
replace_draw()
|
||||
|
||||
return ui_ignore_store
|
||||
|
||||
|
||||
def ui_draw_filter_unregister(ui_ignore_store):
|
||||
for (obj, attr, value) in ui_ignore_store:
|
||||
setattr(obj, attr, value)
|
||||
153
blender-5.2.0/scripts/modules/bl_app_override/helpers.py
Normal file
153
blender-5.2.0/scripts/modules/bl_app_override/helpers.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"AppOverrideState",
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AppOverrideState
|
||||
|
||||
class AppOverrideState:
|
||||
"""
|
||||
Utility class to encapsulate overriding the application state
|
||||
so that settings can be restored afterwards.
|
||||
"""
|
||||
__slots__ = (
|
||||
# setup_classes
|
||||
"_class_store",
|
||||
# setup_ui_ignore
|
||||
"_ui_ignore_store",
|
||||
# setup_addons
|
||||
"_addon_store",
|
||||
)
|
||||
|
||||
# ---------
|
||||
# Callbacks
|
||||
#
|
||||
# Set as None, to make it simple to check if they're being overridden.
|
||||
|
||||
# setup/teardown classes
|
||||
class_ignore = None
|
||||
|
||||
# setup/teardown ui_ignore
|
||||
ui_ignore_classes = None
|
||||
ui_ignore_operator = None
|
||||
ui_ignore_property = None
|
||||
ui_ignore_menu = None
|
||||
ui_ignore_label = None
|
||||
|
||||
addon_paths = None
|
||||
addons = None
|
||||
|
||||
# End callbacks
|
||||
|
||||
def __init__(self):
|
||||
self._class_store = None
|
||||
self._addon_store = None
|
||||
self._ui_ignore_store = None
|
||||
|
||||
def _setup_classes(self):
|
||||
assert self._class_store is None
|
||||
self._class_store = self.class_ignore()
|
||||
from bpy.utils import unregister_class
|
||||
for cls in self._class_store:
|
||||
unregister_class(cls)
|
||||
|
||||
def _teardown_classes(self):
|
||||
assert self._class_store is not None
|
||||
|
||||
from bpy.utils import register_class
|
||||
for cls in self._class_store:
|
||||
register_class(cls)
|
||||
self._class_store = None
|
||||
|
||||
def _setup_ui_ignore(self):
|
||||
import bl_app_override
|
||||
|
||||
self._ui_ignore_store = bl_app_override.ui_draw_filter_register(
|
||||
ui_ignore_classes=(
|
||||
None if self.ui_ignore_classes is None
|
||||
else self.ui_ignore_classes()
|
||||
),
|
||||
ui_ignore_operator=self.ui_ignore_operator,
|
||||
ui_ignore_property=self.ui_ignore_property,
|
||||
ui_ignore_menu=self.ui_ignore_menu,
|
||||
ui_ignore_label=self.ui_ignore_label,
|
||||
)
|
||||
|
||||
def _teardown_ui_ignore(self):
|
||||
import bl_app_override
|
||||
bl_app_override.ui_draw_filter_unregister(
|
||||
self._ui_ignore_store
|
||||
)
|
||||
self._ui_ignore_store = None
|
||||
|
||||
def _setup_addons(self):
|
||||
import sys
|
||||
|
||||
sys_path = []
|
||||
if self.addon_paths is not None:
|
||||
for path in self.addon_paths():
|
||||
if path not in sys.path:
|
||||
sys.path.append(path)
|
||||
|
||||
import addon_utils
|
||||
addons = []
|
||||
if self.addons is not None:
|
||||
addons.extend(self.addons())
|
||||
for addon in addons:
|
||||
addon_utils.enable(addon)
|
||||
|
||||
self._addon_store = {
|
||||
"sys_path": sys_path,
|
||||
"addons": addons,
|
||||
}
|
||||
|
||||
def _teardown_addons(self):
|
||||
import sys
|
||||
|
||||
sys_path = self._addon_store["sys_path"]
|
||||
for path in sys_path:
|
||||
# should always succeed, but if not it doesn't matter
|
||||
# (someone else was changing the sys.path), ignore!
|
||||
try:
|
||||
sys.path.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
addons = self._addon_store["addons"]
|
||||
import addon_utils
|
||||
for addon in addons:
|
||||
addon_utils.disable(addon)
|
||||
|
||||
self._addon_store.clear()
|
||||
self._addon_store = None
|
||||
|
||||
def setup(self):
|
||||
if self.class_ignore is not None:
|
||||
self._setup_classes()
|
||||
|
||||
if any((self.addon_paths,
|
||||
self.addons,
|
||||
)):
|
||||
self._setup_addons()
|
||||
|
||||
if any((self.ui_ignore_operator,
|
||||
self.ui_ignore_property,
|
||||
self.ui_ignore_menu,
|
||||
self.ui_ignore_label,
|
||||
)):
|
||||
self._setup_ui_ignore()
|
||||
|
||||
def teardown(self):
|
||||
if self._class_store is not None:
|
||||
self._teardown_classes()
|
||||
|
||||
if self._addon_store is not None:
|
||||
self._teardown_addons()
|
||||
|
||||
if self._ui_ignore_store is not None:
|
||||
self._teardown_ui_ignore()
|
||||
177
blender-5.2.0/scripts/modules/bl_app_template_utils.py
Normal file
177
blender-5.2.0/scripts/modules/bl_app_template_utils.py
Normal file
@@ -0,0 +1,177 @@
|
||||
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
Similar to ``addon_utils``, except we can only have one active at a time.
|
||||
|
||||
In most cases users of this module will simply call 'activate'.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"activate",
|
||||
"import_from_path",
|
||||
"import_from_id",
|
||||
"reset",
|
||||
)
|
||||
|
||||
import bpy as _bpy
|
||||
|
||||
# Normally matches 'preferences.app_template_id',
|
||||
# but loading new preferences will get us out of sync.
|
||||
_app_template = {
|
||||
"id": "",
|
||||
}
|
||||
|
||||
# Instead of `sys.modules`
|
||||
# note that we only ever have one template enabled at a time
|
||||
# so it may not seem necessary to use this.
|
||||
#
|
||||
# However, templates may want to share between each-other,
|
||||
# so any loaded modules are stored here?
|
||||
#
|
||||
# Note that the ID here is the app_template_id , not the modules __name__.
|
||||
_modules = {}
|
||||
|
||||
|
||||
def _enable(template_id, *, handle_error=None, ignore_not_found=False):
|
||||
from _bpy_restrict_state import RestrictBlend
|
||||
|
||||
if handle_error is None:
|
||||
def handle_error(_ex):
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Split registering up into 2 steps so we can undo
|
||||
# if it fails par way through.
|
||||
|
||||
# disable the context, using the context at all is
|
||||
# really bad while loading an template, don't do it!
|
||||
with RestrictBlend():
|
||||
|
||||
# 1) try import
|
||||
try:
|
||||
mod = import_from_id(template_id, ignore_not_found=ignore_not_found)
|
||||
except Exception as ex:
|
||||
handle_error(ex)
|
||||
return None
|
||||
|
||||
_modules[template_id] = mod
|
||||
if mod is None:
|
||||
return None
|
||||
mod.__template_enabled__ = False
|
||||
|
||||
# 2) try run the modules register function
|
||||
try:
|
||||
mod.register()
|
||||
except Exception as ex:
|
||||
print("Exception in module register(): {!r}".format(getattr(mod, "__file__", template_id)))
|
||||
handle_error(ex)
|
||||
del _modules[template_id]
|
||||
return None
|
||||
|
||||
# * OK loaded successfully! *
|
||||
mod.__template_enabled__ = True
|
||||
|
||||
if _bpy.app.debug_python:
|
||||
print("\tapp_template_utils.enable", mod.__name__)
|
||||
|
||||
return mod
|
||||
|
||||
|
||||
def _disable(template_id, *, handle_error=None):
|
||||
"""
|
||||
Disables a template by name.
|
||||
|
||||
:param template_id: The name of the template and module.
|
||||
:type template_id: str
|
||||
:param handle_error: Called in the case of an error,
|
||||
taking an exception argument.
|
||||
:type handle_error: Callable[[Exception], None] | None
|
||||
"""
|
||||
|
||||
if handle_error is None:
|
||||
def handle_error(_ex):
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
mod = _modules.get(template_id, False)
|
||||
|
||||
if mod is None:
|
||||
# Loaded but has no module, remove since there is no use in keeping it.
|
||||
del _modules[template_id]
|
||||
elif getattr(mod, "__template_enabled__", False) is not False:
|
||||
mod.__template_enabled__ = False
|
||||
|
||||
try:
|
||||
mod.unregister()
|
||||
except Exception as ex:
|
||||
print("Exception in module unregister(): {!r}".format(getattr(mod, "__file__", template_id)))
|
||||
handle_error(ex)
|
||||
else:
|
||||
print(
|
||||
"\tapp_template_utils.disable: {:s} not {:s}.".format(
|
||||
template_id,
|
||||
"disabled" if mod is False else "loaded",
|
||||
)
|
||||
)
|
||||
|
||||
if _bpy.app.debug_python:
|
||||
print("\tapp_template_utils.disable", template_id)
|
||||
|
||||
|
||||
def import_from_path(path, *, ignore_not_found=False):
|
||||
import os
|
||||
from importlib import import_module
|
||||
base_module, template_id = path.rsplit(os.sep, 2)[-2:]
|
||||
module_name = base_module + "." + template_id
|
||||
|
||||
try:
|
||||
return import_module(module_name)
|
||||
except ModuleNotFoundError as ex:
|
||||
if ignore_not_found and ex.name == module_name:
|
||||
return None
|
||||
raise ex
|
||||
|
||||
|
||||
def import_from_id(template_id, *, ignore_not_found=False):
|
||||
import os
|
||||
path = next(iter(_bpy.utils.app_template_paths(path=template_id)), None)
|
||||
if path is None:
|
||||
if ignore_not_found:
|
||||
return None
|
||||
else:
|
||||
raise Exception("{!r} template not found!".format(template_id))
|
||||
else:
|
||||
if ignore_not_found:
|
||||
if not os.path.exists(os.path.join(path, "__init__.py")):
|
||||
return None
|
||||
return import_from_path(path, ignore_not_found=ignore_not_found)
|
||||
|
||||
|
||||
def activate(*, template_id=None, reload_scripts=False):
|
||||
template_id_prev = _app_template["id"]
|
||||
|
||||
# not needed but may as well avoids redundant
|
||||
# disable/enable for all add-ons on "File -> New".
|
||||
if not reload_scripts and template_id_prev == template_id:
|
||||
return
|
||||
|
||||
if template_id_prev:
|
||||
_disable(template_id_prev)
|
||||
|
||||
# ignore_not_found so modules that don't contain scripts don't raise errors
|
||||
_mod = _enable(template_id, ignore_not_found=True) if template_id else None
|
||||
|
||||
_app_template["id"] = template_id
|
||||
|
||||
|
||||
def reset(*, reload_scripts=False):
|
||||
"""
|
||||
Sets default state.
|
||||
"""
|
||||
template_id = _bpy.context.preferences.app_template
|
||||
if _bpy.app.debug_python:
|
||||
print("bl_app_template_utils.reset('{:s}')".format(template_id))
|
||||
|
||||
activate(template_id=template_id, reload_scripts=reload_scripts)
|
||||
@@ -0,0 +1,9 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"io",
|
||||
"keymap_from_toolbar",
|
||||
"keymap_hierarchy",
|
||||
)
|
||||
308
blender-5.2.0/scripts/modules/bl_keymap_utils/io.py
Normal file
308
blender-5.2.0/scripts/modules/bl_keymap_utils/io.py
Normal file
@@ -0,0 +1,308 @@
|
||||
# SPDX-FileCopyrightText: 2018-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Export Functions
|
||||
|
||||
__all__ = (
|
||||
"_init_properties_from_data", # Shared with gizmo default property initialization.
|
||||
"keyconfig_export_as_data",
|
||||
"keyconfig_import_from_data",
|
||||
"keyconfig_init_from_data",
|
||||
"keyconfig_merge",
|
||||
"keymap_init_from_data",
|
||||
)
|
||||
|
||||
|
||||
def indent(levels):
|
||||
return levels * " "
|
||||
|
||||
|
||||
def round_float_32(f):
|
||||
from struct import pack, unpack
|
||||
return unpack("f", pack("f", f))[0]
|
||||
|
||||
|
||||
def repr_f32(f):
|
||||
f_round = round_float_32(f)
|
||||
f_str = repr(f)
|
||||
f_str_frac = f_str.partition(".")[2]
|
||||
if not f_str_frac:
|
||||
return f_str
|
||||
for i in range(1, len(f_str_frac)):
|
||||
f_test = round(f, i)
|
||||
f_test_round = round_float_32(f_test)
|
||||
if f_test_round == f_round:
|
||||
return "{:.{:d}f}".format(f_test, i)
|
||||
return f_str
|
||||
|
||||
|
||||
def kmi_args_as_data(kmi):
|
||||
s = [
|
||||
f"\"type\": '{kmi.type}'",
|
||||
f"\"value\": '{kmi.value}'"
|
||||
]
|
||||
|
||||
if kmi.any:
|
||||
s.append("\"any\": True")
|
||||
else:
|
||||
for attr in ("shift", "ctrl", "alt", "oskey", "hyper"):
|
||||
if mod := getattr(kmi, attr):
|
||||
s.append(f"\"{attr:s}\": " + ("-1" if mod == -1 else "True"))
|
||||
if (mod := kmi.key_modifier) and (mod != 'NONE'):
|
||||
s.append(f"\"key_modifier\": '{mod:s}'")
|
||||
if (direction := kmi.direction) and (direction != 'ANY'):
|
||||
s.append(f"\"direction\": '{direction:s}'")
|
||||
|
||||
if kmi.repeat:
|
||||
if (
|
||||
(kmi.map_type == 'KEYBOARD' and kmi.value in {'PRESS', 'ANY'}) or
|
||||
(kmi.map_type == 'TEXTINPUT')
|
||||
):
|
||||
s.append("\"repeat\": True")
|
||||
|
||||
return "{" + ", ".join(s) + "}"
|
||||
|
||||
|
||||
def _kmi_properties_to_lines_recursive(level, properties, lines):
|
||||
from bpy.types import OperatorProperties
|
||||
|
||||
def string_value(value):
|
||||
if isinstance(value, (str, bool, int, set)):
|
||||
return repr(value)
|
||||
elif isinstance(value, float):
|
||||
return repr_f32(value)
|
||||
elif getattr(value, '__len__', False):
|
||||
return repr(tuple(value))
|
||||
raise Exception(f"Export key configuration: cannot write {value!r}")
|
||||
|
||||
for pname in properties.bl_rna.properties.keys():
|
||||
if pname != "rna_type":
|
||||
value = getattr(properties, pname)
|
||||
if isinstance(value, OperatorProperties):
|
||||
lines_test = []
|
||||
_kmi_properties_to_lines_recursive(level + 2, value, lines_test)
|
||||
if lines_test:
|
||||
lines.append("(")
|
||||
lines.append(f"\"{pname}\",\n")
|
||||
lines.append(f"{indent(level + 3)}" "[")
|
||||
lines.extend(lines_test)
|
||||
lines.append("],\n")
|
||||
lines.append(f"{indent(level + 3)}" "),\n" f"{indent(level + 2)}")
|
||||
del lines_test
|
||||
elif properties.is_property_set(pname):
|
||||
value = string_value(value)
|
||||
lines.append((f"(\"{pname}\", {value:s}),\n" f"{indent(level + 2)}"))
|
||||
|
||||
|
||||
def _kmi_properties_to_lines(level, kmi_props, lines):
|
||||
if kmi_props is None:
|
||||
return
|
||||
|
||||
lines_test = [f"\"properties\":\n" f"{indent(level + 1)}" "["]
|
||||
_kmi_properties_to_lines_recursive(level, kmi_props, lines_test)
|
||||
if len(lines_test) > 1:
|
||||
lines_test.append("],\n")
|
||||
lines.extend(lines_test)
|
||||
|
||||
|
||||
def _kmi_attrs_or_none(level, kmi):
|
||||
lines = []
|
||||
_kmi_properties_to_lines(level + 1, kmi.properties, lines)
|
||||
if kmi.active is False:
|
||||
lines.append(f"{indent(level)}\"active\":" "False,\n")
|
||||
if not lines:
|
||||
return None
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def keyconfig_export_as_data(wm, kc, filepath, *, all_keymaps=False):
|
||||
# Alternate format
|
||||
|
||||
# Generate a list of keymaps to export:
|
||||
#
|
||||
# First add all user_modified keymaps (found in keyconfigs.user.keymaps list),
|
||||
# then add all remaining keymaps from the currently active custom keyconfig.
|
||||
#
|
||||
# Sort the resulting list according to top context name,
|
||||
# while this isn't essential, it makes comparing keymaps simpler.
|
||||
#
|
||||
# This will create a final list of keymaps that can be used as a "diff" against
|
||||
# the default blender keyconfig, recreating the current setup from a fresh blender
|
||||
# without needing to export keymaps which haven't been edited.
|
||||
|
||||
class FakeKeyConfig:
|
||||
keymaps = []
|
||||
edited_kc = FakeKeyConfig()
|
||||
for km in wm.keyconfigs.user.keymaps:
|
||||
if all_keymaps or km.is_user_modified:
|
||||
edited_kc.keymaps.append(km)
|
||||
# merge edited keymaps with non-default keyconfig, if it exists
|
||||
if kc != wm.keyconfigs.default:
|
||||
export_keymaps = keyconfig_merge(edited_kc, kc)
|
||||
else:
|
||||
export_keymaps = keyconfig_merge(edited_kc, edited_kc)
|
||||
|
||||
# Sort the keymap list by top context name before exporting,
|
||||
# not essential, just convenient to order them predictably.
|
||||
export_keymaps.sort(key=lambda k: k[0].name)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as fh:
|
||||
fw = fh.write
|
||||
|
||||
# Use the file version since it includes the sub-version
|
||||
# which we can bump multiple times between releases.
|
||||
from bpy.app import version_file
|
||||
fw(f"keyconfig_version = {version_file!r}\n")
|
||||
del version_file
|
||||
|
||||
fw("keyconfig_data = \\\n[")
|
||||
|
||||
for km, _kc_x in export_keymaps:
|
||||
km = km.active()
|
||||
fw("(")
|
||||
fw(f"\"{km.name:s}\",\n")
|
||||
fw(f"{indent(2)}" "{")
|
||||
fw(f"\"space_type\": '{km.space_type:s}'")
|
||||
fw(f", \"region_type\": '{km.region_type:s}'")
|
||||
# We can detect from the kind of items.
|
||||
if km.is_modal:
|
||||
fw(", \"modal\": True")
|
||||
fw("},\n")
|
||||
fw(f"{indent(2)}" "{")
|
||||
is_modal = km.is_modal
|
||||
fw("\"items\":\n")
|
||||
fw(f"{indent(3)}[")
|
||||
for kmi in km.keymap_items:
|
||||
if is_modal:
|
||||
kmi_id = kmi.propvalue
|
||||
else:
|
||||
kmi_id = kmi.idname
|
||||
fw("(")
|
||||
kmi_args = kmi_args_as_data(kmi)
|
||||
kmi_data = _kmi_attrs_or_none(4, kmi)
|
||||
fw(f"\"{kmi_id:s}\"")
|
||||
if kmi_data is None:
|
||||
fw(", ")
|
||||
else:
|
||||
fw(",\n" f"{indent(5)}")
|
||||
|
||||
fw(kmi_args)
|
||||
if kmi_data is None:
|
||||
fw(", None),\n")
|
||||
else:
|
||||
fw(",\n")
|
||||
fw(f"{indent(5)}" "{")
|
||||
fw(kmi_data)
|
||||
fw(f"{indent(6)}")
|
||||
fw("},\n" f"{indent(5)}")
|
||||
fw("),\n")
|
||||
fw(f"{indent(4)}")
|
||||
fw("],\n" f"{indent(3)}")
|
||||
fw("},\n" f"{indent(2)}")
|
||||
fw("),\n" f"{indent(1)}")
|
||||
|
||||
fw("]\n")
|
||||
fw("\n\n")
|
||||
fw("if __name__ == \"__main__\":\n")
|
||||
|
||||
# We could remove this in the future, as loading new key-maps in older Blender versions
|
||||
# makes less and less sense as Blender changes.
|
||||
fw(" # Only add keywords that are supported.\n")
|
||||
fw(" from bpy.app import version as blender_version\n")
|
||||
fw(" keywords = {}\n")
|
||||
fw(" if blender_version >= (2, 92, 0):\n")
|
||||
fw(" keywords[\"keyconfig_version\"] = keyconfig_version\n")
|
||||
|
||||
fw(" import os\n")
|
||||
fw(" from bl_keymap_utils.io import keyconfig_import_from_data\n")
|
||||
fw(" keyconfig_import_from_data(\n")
|
||||
fw(" os.path.splitext(os.path.basename(__file__))[0],\n")
|
||||
fw(" keyconfig_data,\n")
|
||||
fw(" **keywords,\n")
|
||||
fw(" )\n")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Import Functions
|
||||
#
|
||||
# NOTE: unlike export, this runs on startup.
|
||||
# Take care making changes that could impact performance.
|
||||
|
||||
def _init_properties_from_data(base_props, base_value):
|
||||
assert type(base_value) is list
|
||||
for attr, value in base_value:
|
||||
if type(value) is list:
|
||||
base_props.property_unset(attr)
|
||||
props = getattr(base_props, attr)
|
||||
_init_properties_from_data(props, value)
|
||||
else:
|
||||
try:
|
||||
setattr(base_props, attr, value)
|
||||
except AttributeError:
|
||||
print(f"Warning: property '{attr}' not found in item '{base_props.__class__.__name__}'")
|
||||
except Exception as ex:
|
||||
print(f"Warning: {ex!r}")
|
||||
|
||||
|
||||
def keymap_init_from_data(km, km_items, is_modal=False):
|
||||
new_fn = getattr(km.keymap_items, "new_modal" if is_modal else "new")
|
||||
for (kmi_idname, kmi_args, kmi_data) in km_items:
|
||||
kmi = new_fn(kmi_idname, **kmi_args)
|
||||
if kmi_data is not None:
|
||||
if not kmi_data.get("active", True):
|
||||
kmi.active = False
|
||||
kmi_props_data = kmi_data.get("properties", None)
|
||||
if kmi_props_data is not None:
|
||||
kmi_props = kmi.properties
|
||||
assert type(kmi_props_data) is list
|
||||
_init_properties_from_data(kmi_props, kmi_props_data)
|
||||
|
||||
|
||||
def keyconfig_init_from_data(kc, keyconfig_data):
|
||||
# Load data in the format defined above.
|
||||
#
|
||||
# Runs at load time, keep this fast!
|
||||
for (km_name, km_args, km_content) in keyconfig_data:
|
||||
km = kc.keymaps.new(km_name, **km_args)
|
||||
km_items = km_content["items"]
|
||||
# Check here instead of inside 'keymap_init_from_data'
|
||||
# because we want to allow both tuple & list types in that case.
|
||||
#
|
||||
# For full keymaps, ensure these are always lists to allow for extending them
|
||||
# in a generic way that doesn't have to check for the type each time.
|
||||
assert type(km_items) is list
|
||||
keymap_init_from_data(km, km_items, is_modal=km_args.get("modal", False))
|
||||
|
||||
|
||||
def keyconfig_import_from_data(name, keyconfig_data, *, keyconfig_version=(0, 0, 0)):
|
||||
# Load data in the format defined above.
|
||||
#
|
||||
# Runs at load time, keep this fast!
|
||||
|
||||
import bpy
|
||||
wm = bpy.context.window_manager
|
||||
kc = wm.keyconfigs.new(name)
|
||||
if keyconfig_version is not None:
|
||||
from .versioning import keyconfig_update
|
||||
keyconfig_data = keyconfig_update(keyconfig_data, keyconfig_version)
|
||||
keyconfig_init_from_data(kc, keyconfig_data)
|
||||
return kc
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utility Functions
|
||||
|
||||
def keyconfig_merge(kc1, kc2):
|
||||
""" note: kc1 takes priority over kc2
|
||||
"""
|
||||
kc1_names = {km.name for km in kc1.keymaps}
|
||||
merged_keymaps = [(km, kc1) for km in kc1.keymaps]
|
||||
if kc1 != kc2:
|
||||
merged_keymaps.extend(
|
||||
(km, kc2)
|
||||
for km in kc2.keymaps
|
||||
if km.name not in kc1_names
|
||||
)
|
||||
return merged_keymaps
|
||||
@@ -0,0 +1,394 @@
|
||||
# SPDX-FileCopyrightText: 2018-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Dynamically create a keymap which is used by the popup toolbar
|
||||
# for accelerator key access.
|
||||
|
||||
__all__ = (
|
||||
"generate",
|
||||
)
|
||||
|
||||
|
||||
def generate(context, space_type, *, use_fallback_keys=True, use_reset=True):
|
||||
"""
|
||||
Keymap for popup toolbar, currently generated each time.
|
||||
"""
|
||||
from bl_ui.space_toolsystem_common import ToolSelectPanelHelper
|
||||
|
||||
def modifier_keywords_from_item(kmi):
|
||||
kw = {}
|
||||
for (attr, default) in (
|
||||
("any", False),
|
||||
("shift", False),
|
||||
("ctrl", False),
|
||||
("alt", False),
|
||||
("oskey", False),
|
||||
("hyper", False),
|
||||
("key_modifier", 'NONE'),
|
||||
):
|
||||
val = getattr(kmi, attr)
|
||||
if val != default:
|
||||
kw[attr] = val
|
||||
return kw
|
||||
|
||||
def dict_as_tuple(d):
|
||||
return tuple((k, v) for (k, v) in sorted(d.items()))
|
||||
|
||||
cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
|
||||
|
||||
items_all = [
|
||||
# 0: tool
|
||||
# 1: keymap item (direct access)
|
||||
# 2: keymap item (newly calculated for toolbar)
|
||||
[item, None, None]
|
||||
for item in ToolSelectPanelHelper._tools_flatten(cls.tools_from_context(context))
|
||||
if item is not None
|
||||
]
|
||||
items_all_id = {item_container[0].idname for item_container in items_all}
|
||||
|
||||
# Press the toolbar popup key again to set the default tool,
|
||||
# this is useful because the select box tool is useful as a way
|
||||
# to 'drop' currently active tools (it's basically a 'none' tool).
|
||||
# so this allows us to quickly go back to a state that allows
|
||||
# a shortcut based workflow (before the tool system was added).
|
||||
use_tap_reset = use_reset
|
||||
# TODO: support other tools for modes which don't use this tool.
|
||||
tap_reset_tool = "builtin.cursor"
|
||||
# Check the tool is available in the current context.
|
||||
if tap_reset_tool not in items_all_id:
|
||||
use_tap_reset = False
|
||||
|
||||
# Pie-menu style release to activate.
|
||||
use_release_confirm = use_reset
|
||||
|
||||
# Generate items when no keys are mapped.
|
||||
use_auto_keymap_alpha = False # Map manually in the default key-map.
|
||||
use_auto_keymap_num = use_fallback_keys
|
||||
|
||||
# Temporary, only create so we can pass 'properties' to find_item_from_operator.
|
||||
use_hack_properties = True
|
||||
|
||||
km_name_default = "Toolbar Popup"
|
||||
km_name = km_name_default + " <temp>"
|
||||
wm = context.window_manager
|
||||
keyconf_user = wm.keyconfigs.user
|
||||
keyconf_active = wm.keyconfigs.active
|
||||
|
||||
keymap = keyconf_active.keymaps.get(km_name)
|
||||
if keymap is None:
|
||||
keymap = keyconf_active.keymaps.new(km_name, space_type='EMPTY', region_type='TEMPORARY')
|
||||
for kmi in keymap.keymap_items:
|
||||
keymap.keymap_items.remove(kmi)
|
||||
|
||||
keymap_src = keyconf_user.keymaps.get(km_name_default)
|
||||
if keymap_src is not None:
|
||||
for kmi_src in keymap_src.keymap_items:
|
||||
# Skip tools that aren't currently shown.
|
||||
if (
|
||||
(kmi_src.idname == "wm.tool_set_by_id") and
|
||||
(kmi_src.properties.name not in items_all_id)
|
||||
):
|
||||
continue
|
||||
keymap.keymap_items.new_from_item(kmi_src)
|
||||
del keymap_src
|
||||
del items_all_id
|
||||
|
||||
kmi_unique_args = set()
|
||||
|
||||
def kmi_unique_or_pass(kmi_args):
|
||||
kmi_unique_len = len(kmi_unique_args)
|
||||
kmi_unique_args.add(dict_as_tuple(kmi_args))
|
||||
return kmi_unique_len != len(kmi_unique_args)
|
||||
|
||||
cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
|
||||
|
||||
if use_hack_properties:
|
||||
kmi_hack = keymap.keymap_items.new("wm.tool_set_by_id", 'NONE', 'PRESS')
|
||||
kmi_hack_properties = kmi_hack.properties
|
||||
kmi_hack.active = False
|
||||
|
||||
if use_release_confirm or use_tap_reset:
|
||||
kmi_toolbar = wm.keyconfigs.find_item_from_operator(
|
||||
idname="wm.toolbar",
|
||||
)[1]
|
||||
kmi_toolbar_type = None if not kmi_toolbar else kmi_toolbar.type
|
||||
if use_tap_reset and kmi_toolbar_type is not None:
|
||||
kmi_toolbar_args_type_only = {"type": kmi_toolbar_type}
|
||||
kmi_toolbar_args = {**kmi_toolbar_args_type_only, **modifier_keywords_from_item(kmi_toolbar)}
|
||||
else:
|
||||
use_tap_reset = False
|
||||
del kmi_toolbar
|
||||
|
||||
if use_tap_reset:
|
||||
kmi_found = None
|
||||
if use_hack_properties:
|
||||
# First check for direct assignment, if this tool already has a key, no need to add a new one.
|
||||
kmi_hack_properties.name = tap_reset_tool
|
||||
kmi_found = wm.keyconfigs.find_item_from_operator(
|
||||
idname="wm.tool_set_by_id",
|
||||
context='INVOKE_REGION_WIN',
|
||||
# properties={"name": item.idname},
|
||||
properties=kmi_hack_properties,
|
||||
include={'KEYBOARD'},
|
||||
)[1]
|
||||
if kmi_found:
|
||||
use_tap_reset = False
|
||||
del kmi_found
|
||||
|
||||
if use_tap_reset:
|
||||
use_tap_reset = kmi_unique_or_pass(kmi_toolbar_args)
|
||||
|
||||
if use_tap_reset:
|
||||
items_all[:] = [
|
||||
item_container
|
||||
for item_container in items_all
|
||||
if item_container[0].idname != tap_reset_tool
|
||||
]
|
||||
|
||||
# -----------------------
|
||||
# Begin Keymap Generation
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Direct Tool Assignment & Brushes
|
||||
|
||||
for item_container in items_all:
|
||||
item = item_container[0]
|
||||
# Only check the first item in the tools key-map (a little arbitrary).
|
||||
if use_hack_properties:
|
||||
# First check for direct assignment.
|
||||
kmi_hack_properties.name = item.idname
|
||||
kmi_found = wm.keyconfigs.find_item_from_operator(
|
||||
idname="wm.tool_set_by_id",
|
||||
context='INVOKE_REGION_WIN',
|
||||
# properties={"name": item.idname},
|
||||
properties=kmi_hack_properties,
|
||||
include={'KEYBOARD'},
|
||||
)[1]
|
||||
|
||||
else:
|
||||
kmi_found = None
|
||||
|
||||
if kmi_found is not None:
|
||||
pass
|
||||
elif item.operator is not None:
|
||||
kmi_found = wm.keyconfigs.find_item_from_operator(
|
||||
idname=item.operator,
|
||||
context='INVOKE_REGION_WIN',
|
||||
include={'KEYBOARD'},
|
||||
)[1]
|
||||
elif item.keymap is not None:
|
||||
km = keyconf_user.keymaps.get(item.keymap[0])
|
||||
if km is None:
|
||||
print("Keymap", repr(item.keymap[0]), "not found for tool", item.idname)
|
||||
kmi_found = None
|
||||
else:
|
||||
kmi_first = km.keymap_items
|
||||
kmi_first = kmi_first[0] if kmi_first else None
|
||||
if kmi_first is not None:
|
||||
kmi_found = wm.keyconfigs.find_item_from_operator(
|
||||
idname=kmi_first.idname,
|
||||
# properties=kmi_first.properties, # prevents matches, don't use.
|
||||
context='INVOKE_REGION_WIN',
|
||||
include={'KEYBOARD'},
|
||||
)[1]
|
||||
if kmi_found is None:
|
||||
# We need non-keyboard events so keys with 'key_modifier' key is found.
|
||||
kmi_found = wm.keyconfigs.find_item_from_operator(
|
||||
idname=kmi_first.idname,
|
||||
# properties=kmi_first.properties, # prevents matches, don't use.
|
||||
context='INVOKE_REGION_WIN',
|
||||
exclude={'KEYBOARD'},
|
||||
)[1]
|
||||
if kmi_found is not None:
|
||||
if kmi_found.key_modifier == 'NONE':
|
||||
kmi_found = None
|
||||
else:
|
||||
kmi_found = None
|
||||
del kmi_first
|
||||
del km
|
||||
else:
|
||||
kmi_found = None
|
||||
item_container[1] = kmi_found
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Single Key Access
|
||||
|
||||
# More complex multi-pass test.
|
||||
for item_container in items_all:
|
||||
item, kmi_found = item_container[:2]
|
||||
if kmi_found is None:
|
||||
continue
|
||||
kmi_found_type = kmi_found.type
|
||||
|
||||
# Only for single keys.
|
||||
if (
|
||||
(len(kmi_found_type) == 1) or
|
||||
# When a tool is being activated instead of running an operator, just copy the shortcut.
|
||||
(kmi_found.idname in {"wm.tool_set_by_id", "WM_OT_tool_set_by_id"})
|
||||
):
|
||||
kmi_args = {"type": kmi_found_type, **modifier_keywords_from_item(kmi_found)}
|
||||
if kmi_unique_or_pass(kmi_args):
|
||||
kmi = keymap.keymap_items.new(idname="wm.tool_set_by_id", value='PRESS', **kmi_args)
|
||||
kmi.properties.name = item.idname
|
||||
item_container[2] = kmi
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Single Key Modifier
|
||||
#
|
||||
#
|
||||
# Test for key_modifier, where alpha key is used as a 'key_modifier'
|
||||
# (grease pencil holding 'D' for example).
|
||||
|
||||
for item_container in items_all:
|
||||
item, kmi_found, kmi_exist = item_container
|
||||
if kmi_found is None or kmi_exist:
|
||||
continue
|
||||
|
||||
kmi_found_type = kmi_found.type
|
||||
if kmi_found_type in {
|
||||
'LEFTMOUSE',
|
||||
'RIGHTMOUSE',
|
||||
'MIDDLEMOUSE',
|
||||
'BUTTON4MOUSE',
|
||||
'BUTTON5MOUSE',
|
||||
'BUTTON6MOUSE',
|
||||
'BUTTON7MOUSE',
|
||||
}:
|
||||
kmi_found_type = kmi_found.key_modifier
|
||||
# excludes 'NONE'
|
||||
if len(kmi_found_type) == 1:
|
||||
kmi_args = {"type": kmi_found_type, **modifier_keywords_from_item(kmi_found)}
|
||||
del kmi_args["key_modifier"]
|
||||
if kmi_unique_or_pass(kmi_args):
|
||||
kmi = keymap.keymap_items.new(idname="wm.tool_set_by_id", value='PRESS', **kmi_args)
|
||||
kmi.properties.name = item.idname
|
||||
item_container[2] = kmi
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Assign A-Z to Keys
|
||||
#
|
||||
# When the keys are free.
|
||||
|
||||
if use_auto_keymap_alpha:
|
||||
# Map all unmapped keys to numbers,
|
||||
# while this is a bit strange it means users will not confuse regular key bindings to ordered bindings.
|
||||
|
||||
# First map A-Z.
|
||||
kmi_type_alpha_char = [chr(i) for i in range(65, 91)]
|
||||
kmi_type_alpha_args = {c: {"type": c} for c in kmi_type_alpha_char}
|
||||
kmi_type_alpha_args_tuple = {c: dict_as_tuple(kmi_type_alpha_args[c]) for c in kmi_type_alpha_char}
|
||||
for item_container in items_all:
|
||||
item, kmi_found, kmi_exist = item_container
|
||||
if kmi_exist:
|
||||
continue
|
||||
kmi_type = item.label[0].upper()
|
||||
kmi_tuple = kmi_type_alpha_args_tuple.get(kmi_type)
|
||||
if kmi_tuple and kmi_tuple not in kmi_unique_args:
|
||||
kmi_unique_args.add(kmi_tuple)
|
||||
kmi = keymap.keymap_items.new(
|
||||
idname="wm.tool_set_by_id",
|
||||
value='PRESS',
|
||||
**kmi_type_alpha_args[kmi_type],
|
||||
)
|
||||
kmi.properties.name = item.idname
|
||||
item_container[2] = kmi
|
||||
del kmi_type_alpha_char, kmi_type_alpha_args, kmi_type_alpha_args_tuple
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Assign Numbers to Keys
|
||||
|
||||
if use_auto_keymap_num:
|
||||
# Free events (last used first).
|
||||
kmi_type_auto = ('ONE', 'TWO', 'THREE', 'FOUR', 'FIVE', 'SIX', 'SEVEN', 'EIGHT', 'NINE', 'ZERO')
|
||||
# Map both numbers and num-pad.
|
||||
kmi_type_dupe = {
|
||||
'ONE': 'NUMPAD_1',
|
||||
'TWO': 'NUMPAD_2',
|
||||
'THREE': 'NUMPAD_3',
|
||||
'FOUR': 'NUMPAD_4',
|
||||
'FIVE': 'NUMPAD_5',
|
||||
'SIX': 'NUMPAD_6',
|
||||
'SEVEN': 'NUMPAD_7',
|
||||
'EIGHT': 'NUMPAD_8',
|
||||
'NINE': 'NUMPAD_9',
|
||||
'ZERO': 'NUMPAD_0',
|
||||
}
|
||||
|
||||
def iter_free_events():
|
||||
for mod in ({}, {"shift": True}, {"ctrl": True}, {"alt": True}):
|
||||
for e in kmi_type_auto:
|
||||
yield (e, mod)
|
||||
|
||||
iter_events = iter(iter_free_events())
|
||||
|
||||
for item_container in items_all:
|
||||
item, kmi_found, kmi_exist = item_container
|
||||
if kmi_exist:
|
||||
continue
|
||||
kmi_args = None
|
||||
while True:
|
||||
key, mod = next(iter_events, (None, None))
|
||||
if key is None:
|
||||
break
|
||||
kmi_args = {"type": key, **mod}
|
||||
kmi_tuple = dict_as_tuple(kmi_args)
|
||||
if kmi_tuple in kmi_unique_args:
|
||||
kmi_args = None
|
||||
else:
|
||||
break
|
||||
|
||||
if kmi_args is not None:
|
||||
kmi = keymap.keymap_items.new(idname="wm.tool_set_by_id", value='PRESS', **kmi_args)
|
||||
kmi.properties.name = item.idname
|
||||
item_container[2] = kmi
|
||||
kmi_unique_args.add(kmi_tuple)
|
||||
|
||||
key = kmi_type_dupe.get(kmi_args["type"])
|
||||
if key is not None:
|
||||
kmi_args["type"] = key
|
||||
kmi_tuple = dict_as_tuple(kmi_args)
|
||||
if kmi_tuple not in kmi_unique_args:
|
||||
kmi = keymap.keymap_items.new(idname="wm.tool_set_by_id", value='PRESS', **kmi_args)
|
||||
kmi.properties.name = item.idname
|
||||
kmi_unique_args.add(kmi_tuple)
|
||||
|
||||
# ---------------------
|
||||
# End Keymap Generation
|
||||
|
||||
if use_hack_properties:
|
||||
keymap.keymap_items.remove(kmi_hack)
|
||||
|
||||
# Keep last so we can try add a key without any modifiers
|
||||
# in the case this toolbar was activated with modifiers.
|
||||
if use_tap_reset:
|
||||
if len(kmi_toolbar_args_type_only) == len(kmi_toolbar_args):
|
||||
kmi_toolbar_args_available = kmi_toolbar_args
|
||||
else:
|
||||
# We have modifiers, see if we have a free key w/o modifiers.
|
||||
kmi_toolbar_tuple = dict_as_tuple(kmi_toolbar_args_type_only)
|
||||
if kmi_toolbar_tuple not in kmi_unique_args:
|
||||
kmi_toolbar_args_available = kmi_toolbar_args_type_only
|
||||
kmi_unique_args.add(kmi_toolbar_tuple)
|
||||
else:
|
||||
kmi_toolbar_args_available = kmi_toolbar_args
|
||||
del kmi_toolbar_tuple
|
||||
|
||||
kmi = keymap.keymap_items.new(
|
||||
"wm.tool_set_by_id",
|
||||
value='DOUBLE_CLICK',
|
||||
**kmi_toolbar_args_available,
|
||||
)
|
||||
kmi.properties.name = tap_reset_tool
|
||||
|
||||
if use_release_confirm and (kmi_toolbar_type is not None):
|
||||
kmi = keymap.keymap_items.new(
|
||||
"ui.button_execute",
|
||||
type=kmi_toolbar_type,
|
||||
value='RELEASE',
|
||||
any=True,
|
||||
)
|
||||
kmi.properties.skip_depressed = True
|
||||
|
||||
wm.keyconfigs.update()
|
||||
return keymap
|
||||
@@ -0,0 +1,248 @@
|
||||
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"generate",
|
||||
)
|
||||
|
||||
|
||||
def _km_expand_from_toolsystem(space_type, context_mode):
|
||||
def _fn():
|
||||
from bl_ui.space_toolsystem_common import ToolSelectPanelHelper
|
||||
for cls in ToolSelectPanelHelper.__subclasses__():
|
||||
if cls.bl_space_type == space_type:
|
||||
return cls.keymap_ui_hierarchy(context_mode)
|
||||
raise Exception("keymap not found")
|
||||
return _fn
|
||||
|
||||
|
||||
def _km_hierarchy_iter_recursive(items):
|
||||
for sub in items:
|
||||
if callable(sub):
|
||||
yield from sub()
|
||||
else:
|
||||
yield (*sub[:3], list(_km_hierarchy_iter_recursive(sub[3])))
|
||||
|
||||
|
||||
def generate():
|
||||
import bpy
|
||||
|
||||
if bpy.app.background:
|
||||
from bl_ui.space_toolsystem_common import ToolSelectPanelHelper
|
||||
for cls in ToolSelectPanelHelper.__subclasses__():
|
||||
cls.register_ensure()
|
||||
|
||||
return list(_km_hierarchy_iter_recursive(_km_hierarchy))
|
||||
|
||||
|
||||
# bpy.type.KeyMap: (km.name, km.space_type, km.region_type, [...])
|
||||
|
||||
# ('Script', 'EMPTY', 'WINDOW', []),
|
||||
|
||||
|
||||
# Access via 'km_hierarchy'.
|
||||
_km_hierarchy = [
|
||||
('Window', 'EMPTY', 'WINDOW', []), # file save, window change, exit
|
||||
('Screen', 'EMPTY', 'WINDOW', [ # full screen, undo, screenshot
|
||||
('Screen Editing', 'EMPTY', 'WINDOW', []), # re-sizing, action corners
|
||||
('Region Context Menu', 'EMPTY', 'WINDOW', []), # header/footer/navigation_bar stuff (per region)
|
||||
]),
|
||||
|
||||
('View2D', 'EMPTY', 'WINDOW', []), # view 2d navigation (per region)
|
||||
('View2D Buttons List', 'EMPTY', 'WINDOW', []), # view 2d with buttons navigation
|
||||
|
||||
('User Interface', 'EMPTY', 'WINDOW', []),
|
||||
|
||||
('3D View', 'VIEW_3D', 'WINDOW', [ # view 3d navigation and generic stuff (select, transform)
|
||||
('Object Mode', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'OBJECT'),
|
||||
]),
|
||||
('Mesh', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'EDIT_MESH'),
|
||||
]),
|
||||
('Curve', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'EDIT_CURVE'),
|
||||
]),
|
||||
('Curves', 'EMPTY', 'WINDOW', []),
|
||||
('Armature', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'EDIT_ARMATURE'),
|
||||
]),
|
||||
('Metaball', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'EDIT_METABALL'),
|
||||
]),
|
||||
('Lattice', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'EDIT_LATTICE'),
|
||||
]),
|
||||
('Font', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'EDIT_TEXT'),
|
||||
]),
|
||||
('Grease Pencil', 'EMPTY', 'WINDOW', []),
|
||||
('Point Cloud', 'EMPTY', 'WINDOW', []),
|
||||
('Pose', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'POSE'),
|
||||
]),
|
||||
|
||||
('Vertex Paint', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'PAINT_VERTEX'),
|
||||
]),
|
||||
('Weight Paint', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'PAINT_WEIGHT'),
|
||||
]),
|
||||
('Paint Vertex Selection (Weight, Vertex)', 'EMPTY', 'WINDOW', []),
|
||||
('Paint Face Mask (Weight, Vertex, Texture)', 'EMPTY', 'WINDOW', []),
|
||||
# image and view3d
|
||||
('Image Paint', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'PAINT_TEXTURE'),
|
||||
]),
|
||||
('Sculpt', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'SCULPT'),
|
||||
]),
|
||||
|
||||
('Sculpt Curves', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'CURVES_SCULPT'),
|
||||
]),
|
||||
|
||||
('Particle', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', 'PARTICLE'),
|
||||
]),
|
||||
|
||||
('Primitive Tool Modal Map', 'EMPTY', 'WINDOW', []),
|
||||
('Pen Tool Modal Map', 'EMPTY', 'WINDOW', []),
|
||||
('Knife Tool Modal Map', 'EMPTY', 'WINDOW', []),
|
||||
('Custom Normals Modal Map', 'EMPTY', 'WINDOW', []),
|
||||
('Bevel Modal Map', 'EMPTY', 'WINDOW', []),
|
||||
('Paint Stroke Modal', 'EMPTY', 'WINDOW', []),
|
||||
('Sculpt Expand Modal', 'EMPTY', 'WINDOW', []),
|
||||
('Paint Curve', 'EMPTY', 'WINDOW', []),
|
||||
('Curve Pen Modal Map', 'EMPTY', 'WINDOW', []),
|
||||
|
||||
('Object Non-modal', 'EMPTY', 'WINDOW', []), # mode change
|
||||
|
||||
('View3D Placement Modal', 'EMPTY', 'WINDOW', []),
|
||||
('View3D Walk Modal', 'EMPTY', 'WINDOW', []),
|
||||
('View3D Fly Modal', 'EMPTY', 'WINDOW', []),
|
||||
('View3D Rotate Modal', 'EMPTY', 'WINDOW', []),
|
||||
('View3D Move Modal', 'EMPTY', 'WINDOW', []),
|
||||
('View3D Zoom Modal', 'EMPTY', 'WINDOW', []),
|
||||
('View3D Dolly Modal', 'EMPTY', 'WINDOW', []),
|
||||
('View3D VR Location Scouting Capture Review Modal', 'EMPTY', 'WINDOW', []),
|
||||
|
||||
# toolbar and properties
|
||||
('3D View Generic', 'VIEW_3D', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('VIEW_3D', None),
|
||||
]),
|
||||
]),
|
||||
|
||||
('Graph Editor', 'GRAPH_EDITOR', 'WINDOW', [
|
||||
('Graph Editor Generic', 'GRAPH_EDITOR', 'WINDOW', []),
|
||||
]),
|
||||
('Dopesheet', 'DOPESHEET_EDITOR', 'WINDOW', [
|
||||
('Dopesheet Generic', 'DOPESHEET_EDITOR', 'WINDOW', []),
|
||||
]),
|
||||
('NLA Editor', 'NLA_EDITOR', 'WINDOW', [
|
||||
('NLA Tracks', 'NLA_EDITOR', 'WINDOW', []),
|
||||
('NLA Generic', 'NLA_EDITOR', 'WINDOW', []),
|
||||
]),
|
||||
|
||||
('Image', 'IMAGE_EDITOR', 'WINDOW', [
|
||||
# Image (reverse order, UVEdit before Image).
|
||||
('UV Editor', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('IMAGE_EDITOR', 'UV'),
|
||||
]),
|
||||
('UV Sculpt', 'EMPTY', 'WINDOW', []),
|
||||
# Image and view3d.
|
||||
('Image Paint', 'EMPTY', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('IMAGE_EDITOR', 'PAINT'),
|
||||
]),
|
||||
('Image View', 'IMAGE_EDITOR', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('IMAGE_EDITOR', 'VIEW'),
|
||||
]),
|
||||
('Image Generic', 'IMAGE_EDITOR', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('IMAGE_EDITOR', None),
|
||||
]),
|
||||
]),
|
||||
|
||||
('Outliner', 'OUTLINER', 'WINDOW', []),
|
||||
|
||||
('Node Editor', 'NODE_EDITOR', 'WINDOW', [
|
||||
('Node Generic', 'NODE_EDITOR', 'WINDOW', []),
|
||||
('Node Tool: Tweak', 'NODE_EDITOR', 'WINDOW', []),
|
||||
('Node Tool: Tweak (fallback)', 'NODE_EDITOR', 'WINDOW', []),
|
||||
('Node Tool: Select Box', 'NODE_EDITOR', 'WINDOW', []),
|
||||
('Node Tool: Select Box (fallback)', 'NODE_EDITOR', 'WINDOW', []),
|
||||
('Node Tool: Select Lasso', 'NODE_EDITOR', 'WINDOW', []),
|
||||
('Node Tool: Select Lasso (fallback)', 'NODE_EDITOR', 'WINDOW', []),
|
||||
('Node Tool: Select Circle', 'NODE_EDITOR', 'WINDOW', []),
|
||||
('Node Tool: Select Circle (fallback)', 'NODE_EDITOR', 'WINDOW', []),
|
||||
]),
|
||||
|
||||
('Spreadsheet Generic', 'SPREADSHEET', 'WINDOW', []),
|
||||
|
||||
('Video Sequence Editor', 'SEQUENCE_EDITOR', 'WINDOW', [
|
||||
('Sequencer', 'SEQUENCE_EDITOR', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('SEQUENCE_EDITOR', 'SEQUENCER'),
|
||||
]),
|
||||
('Preview', 'SEQUENCE_EDITOR', 'WINDOW', [
|
||||
_km_expand_from_toolsystem('SEQUENCE_EDITOR', 'PREVIEW'),
|
||||
]),
|
||||
]),
|
||||
|
||||
('File Browser', 'FILE_BROWSER', 'WINDOW', [
|
||||
('File Browser Main', 'FILE_BROWSER', 'WINDOW', []),
|
||||
('File Browser Buttons', 'FILE_BROWSER', 'WINDOW', []),
|
||||
]),
|
||||
|
||||
('Info', 'INFO', 'WINDOW', []),
|
||||
|
||||
('Property Editor', 'PROPERTIES', 'WINDOW', []), # align context menu
|
||||
|
||||
('Text', 'TEXT_EDITOR', 'WINDOW', [
|
||||
('Text Generic', 'TEXT_EDITOR', 'WINDOW', []),
|
||||
]),
|
||||
('Console', 'CONSOLE', 'WINDOW', []),
|
||||
('Clip', 'CLIP_EDITOR', 'WINDOW', [
|
||||
('Clip Editor', 'CLIP_EDITOR', 'WINDOW', []),
|
||||
('Clip Graph Editor', 'CLIP_EDITOR', 'WINDOW', []),
|
||||
('Clip Dopesheet Editor', 'CLIP_EDITOR', 'WINDOW', []),
|
||||
]),
|
||||
|
||||
('Grease Pencil', 'EMPTY', 'WINDOW', [
|
||||
# Grease Pencil
|
||||
('Grease Pencil Draw Mode', 'EMPTY', 'WINDOW', []),
|
||||
('Grease Pencil Brush Stroke', 'EMPTY', 'WINDOW', []),
|
||||
('Grease Pencil Edit Mode', 'EMPTY', 'WINDOW', []),
|
||||
('Grease Pencil Sculpt Mode', 'EMPTY', 'WINDOW', []),
|
||||
('Grease Pencil Weight Paint', 'EMPTY', 'WINDOW', []),
|
||||
('Grease Pencil Vertex Paint', 'EMPTY', 'WINDOW', []),
|
||||
# Grease Pencil Fill Tool
|
||||
('Grease Pencil Fill Tool', 'EMPTY', 'WINDOW', []),
|
||||
]),
|
||||
('Mask Editing', 'EMPTY', 'WINDOW', []),
|
||||
('Frames', 'EMPTY', 'WINDOW', []), # frame navigation (per region)
|
||||
('Markers', 'EMPTY', 'WINDOW', []), # markers (per region)
|
||||
('Animation', 'EMPTY', 'WINDOW', []), # frame change on click, preview range (per region)
|
||||
('Animation Channels', 'EMPTY', 'WINDOW', []),
|
||||
|
||||
('View3D Gesture Circle', 'EMPTY', 'WINDOW', []),
|
||||
('Gesture Straight Line', 'EMPTY', 'WINDOW', []),
|
||||
('Gesture Zoom Border', 'EMPTY', 'WINDOW', []),
|
||||
('Gesture Box', 'EMPTY', 'WINDOW', []),
|
||||
|
||||
('Standard Modal Map', 'EMPTY', 'WINDOW', []),
|
||||
('Transform Modal Map', 'EMPTY', 'WINDOW', []),
|
||||
('Eyedropper Modal Map', 'EMPTY', 'WINDOW', []),
|
||||
('Eyedropper ColorRamp PointSampling Map', 'EMPTY', 'WINDOW', []),
|
||||
('Mesh Filter Modal Map', 'EMPTY', 'WINDOW', []),
|
||||
|
||||
# Grease Pencil Fill Tool
|
||||
('Fill Tool Modal Map', 'EMPTY', 'WINDOW', []),
|
||||
|
||||
('Generic Gizmo', 'EMPTY', 'WINDOW', [
|
||||
('Generic Gizmo Drag', 'EMPTY', 'WINDOW', []),
|
||||
('Generic Gizmo Click Drag', 'EMPTY', 'WINDOW', []),
|
||||
('Generic Gizmo Maybe Drag', 'EMPTY', 'WINDOW', []),
|
||||
('Generic Gizmo Select', 'EMPTY', 'WINDOW', []),
|
||||
('Generic Gizmo Tweak Modal Map', 'EMPTY', 'WINDOW', []),
|
||||
]),
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
# SPDX-FileCopyrightText: 2019-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"keyconfig_data_oskey_from_ctrl",
|
||||
"keyconfig_data_oskey_from_ctrl_for_macos",
|
||||
)
|
||||
|
||||
|
||||
def keyconfig_data_oskey_from_ctrl(keyconfig_data_src, *, filter_fn=None):
|
||||
keyconfig_data_dst = []
|
||||
for km_name, km_parms, km_items_data_src in keyconfig_data_src:
|
||||
km_items_data_dst = km_items_data_src.copy()
|
||||
items_dst = []
|
||||
km_items_data_dst["items"] = items_dst
|
||||
for item_src in km_items_data_src["items"]:
|
||||
item_op, item_event, item_prop = item_src
|
||||
if "ctrl" in item_event:
|
||||
if filter_fn is None or filter_fn(item_event):
|
||||
item_event = item_event.copy()
|
||||
item_event["oskey"] = item_event["ctrl"]
|
||||
del item_event["ctrl"]
|
||||
items_dst.append((item_op, item_event, item_prop))
|
||||
items_dst.append(item_src)
|
||||
keyconfig_data_dst.append((km_name, km_parms, km_items_data_dst))
|
||||
return keyconfig_data_dst
|
||||
|
||||
|
||||
def keyconfig_data_oskey_from_ctrl_for_macos(keyconfig_data_src):
|
||||
"""Use for apple since Cmd is typically used in-place of Ctrl."""
|
||||
def filter_fn(item_event):
|
||||
if item_event.get("ctrl"):
|
||||
event_type = item_event["type"]
|
||||
# Ctrl-{Key}
|
||||
if (event_type in {
|
||||
'H',
|
||||
'M',
|
||||
'SPACE',
|
||||
'W',
|
||||
'ACCENT_GRAVE',
|
||||
'PERIOD',
|
||||
'TAB',
|
||||
}):
|
||||
if (not item_event.get("alt")) and (not item_event.get("shift")):
|
||||
return False
|
||||
# Ctrl-Alt-{Key}
|
||||
if (event_type in {
|
||||
'Q',
|
||||
}):
|
||||
if item_event.get("alt") and (not item_event.get("shift")):
|
||||
return False
|
||||
return True
|
||||
|
||||
return keyconfig_data_oskey_from_ctrl(keyconfig_data_src, filter_fn=filter_fn)
|
||||
363
blender-5.2.0/scripts/modules/bl_keymap_utils/versioning.py
Normal file
363
blender-5.2.0/scripts/modules/bl_keymap_utils/versioning.py
Normal file
@@ -0,0 +1,363 @@
|
||||
# SPDX-FileCopyrightText: 2020-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Update Blender version this key-map was written in.
|
||||
# The update runs when loading key-map presets written in older versions of Blender.
|
||||
# Failing to run this means those key-maps may fail to load with an error.
|
||||
#
|
||||
# When the version is `(0, 0, 0)`, the key-map being loaded didn't contain any versioning information.
|
||||
# This will older than `(2, 92, 0)`.
|
||||
|
||||
__all__ = (
|
||||
"keyconfig_update",
|
||||
)
|
||||
|
||||
|
||||
def keyconfig_update(keyconfig_data, keyconfig_version):
|
||||
import re
|
||||
from bpy.app import version_file as blender_version
|
||||
if keyconfig_version >= blender_version:
|
||||
return keyconfig_data
|
||||
|
||||
# Version the key-map.
|
||||
import copy
|
||||
|
||||
# Only copy once.
|
||||
has_copy = False
|
||||
|
||||
def get_transform_modal_map():
|
||||
for km_name, _km_params, km_items_data in keyconfig_data:
|
||||
if km_name == "Transform Modal Map":
|
||||
return km_items_data
|
||||
return None
|
||||
|
||||
def get_ui_keymap():
|
||||
for km_name, _km_params, km_items_data in keyconfig_data:
|
||||
if km_name == "User Interface":
|
||||
return km_items_data
|
||||
return None
|
||||
|
||||
def remove_properties(op_prop_map):
|
||||
nonlocal keyconfig_data
|
||||
nonlocal has_copy
|
||||
|
||||
changed_items = []
|
||||
for km_index, (_km_name, _km_parms, km_items_data) in enumerate(keyconfig_data):
|
||||
for kmi_item_index, (item_op, item_event, item_prop) in enumerate(km_items_data["items"]):
|
||||
if item_prop and item_op in op_prop_map:
|
||||
properties = item_prop.get("properties", [])
|
||||
filtered_properties = [
|
||||
prop for prop in properties if not any(
|
||||
key in prop for key in op_prop_map[item_op])
|
||||
]
|
||||
|
||||
if not filtered_properties:
|
||||
filtered_properties = None
|
||||
|
||||
if filtered_properties is None or len(filtered_properties) < len(properties):
|
||||
changed_items.append((km_index, kmi_item_index, filtered_properties))
|
||||
|
||||
if changed_items:
|
||||
if not has_copy:
|
||||
keyconfig_data = copy.deepcopy(keyconfig_data)
|
||||
has_copy = True
|
||||
|
||||
for km_index, kmi_item_index, filtered_properties in changed_items:
|
||||
item_op, item_event, item_prop = keyconfig_data[km_index][2]["items"][kmi_item_index]
|
||||
item_prop["properties"] = filtered_properties
|
||||
keyconfig_data[km_index][2]["items"][kmi_item_index] = (item_op, item_event, item_prop)
|
||||
|
||||
def rename_keymap(km_name_map):
|
||||
nonlocal keyconfig_data
|
||||
nonlocal has_copy
|
||||
|
||||
for km_index, (km_name, km_parms, km_items_data) in enumerate(keyconfig_data):
|
||||
km_name_dst = km_name_map.get(km_name)
|
||||
if km_name_dst is None:
|
||||
continue
|
||||
if not has_copy:
|
||||
keyconfig_data = copy.deepcopy(keyconfig_data)
|
||||
has_copy = True
|
||||
keyconfig_data[km_index] = (km_name_dst, km_parms, km_items_data)
|
||||
|
||||
# Default repeat to false.
|
||||
if keyconfig_version <= (2, 92, 0):
|
||||
if not has_copy:
|
||||
keyconfig_data = copy.deepcopy(keyconfig_data)
|
||||
has_copy = True
|
||||
|
||||
for _km_name, _km_parms, km_items_data in keyconfig_data:
|
||||
for (_item_op, item_event, _item_prop) in km_items_data["items"]:
|
||||
if item_event.get("value") == 'PRESS':
|
||||
# Unfortunately we don't know the `map_type` at this point.
|
||||
# Setting repeat true on other kinds of events is harmless.
|
||||
item_event["repeat"] = True
|
||||
|
||||
if keyconfig_version <= (3, 2, 5):
|
||||
if not has_copy:
|
||||
keyconfig_data = copy.deepcopy(keyconfig_data)
|
||||
has_copy = True
|
||||
|
||||
for _km_name, _km_parms, km_items_data in keyconfig_data:
|
||||
for (_item_op, item_event, _item_prop) in km_items_data["items"]:
|
||||
if ty_new := {
|
||||
'EVT_TWEAK_L': 'LEFTMOUSE',
|
||||
'EVT_TWEAK_M': 'MIDDLEMOUSE',
|
||||
'EVT_TWEAK_R': 'RIGHTMOUSE',
|
||||
}.get(item_event.get("type")):
|
||||
item_event["type"] = ty_new
|
||||
if (value := item_event["value"]) != 'ANY':
|
||||
item_event["direction"] = value
|
||||
item_event["value"] = 'CLICK_DRAG'
|
||||
|
||||
if keyconfig_version <= (3, 2, 6):
|
||||
if not has_copy:
|
||||
keyconfig_data = copy.deepcopy(keyconfig_data)
|
||||
has_copy = True
|
||||
|
||||
for _km_name, _km_parms, km_items_data in keyconfig_data:
|
||||
for (_item_op, item_event, _item_prop) in km_items_data["items"]:
|
||||
if ty_new := {
|
||||
'NDOF_BUTTON_ESC': 'ESC',
|
||||
'NDOF_BUTTON_ALT': 'LEFT_ALT',
|
||||
'NDOF_BUTTON_SHIFT': 'LEFT_SHIFT',
|
||||
'NDOF_BUTTON_CTRL': 'LEFT_CTRL',
|
||||
}.get(item_event.get("type")):
|
||||
item_event["type"] = ty_new
|
||||
|
||||
if keyconfig_version <= (3, 6, 0):
|
||||
# The modal keys "Vert/Edge Slide" and "TrackBall" didn't exist until then.
|
||||
# The operator reused the "Move" and "Rotate" respectively.
|
||||
if not has_copy:
|
||||
keyconfig_data = copy.deepcopy(keyconfig_data)
|
||||
has_copy = True
|
||||
|
||||
if km_items_data := get_transform_modal_map():
|
||||
km_items = km_items_data["items"]
|
||||
for (item_modal, item_event, _item_prop) in km_items:
|
||||
if item_modal == 'TRANSLATE':
|
||||
km_items.append(('VERT_EDGE_SLIDE', item_event, None))
|
||||
elif item_modal == 'ROTATE':
|
||||
km_items.append(('TRACKBALL', item_event, None))
|
||||
|
||||
# The modal key for "Rotate Normals" also didn't exist until then.
|
||||
km_items.append(('ROTATE_NORMALS', {"type": 'N', "value": 'PRESS'}, None))
|
||||
|
||||
if keyconfig_version <= (4, 0, 3):
|
||||
if not has_copy:
|
||||
keyconfig_data = copy.deepcopy(keyconfig_data)
|
||||
has_copy = True
|
||||
|
||||
# "Snap Source Toggle" did not exist until then.
|
||||
if km_items_data := get_transform_modal_map():
|
||||
km_items_data["items"].append(("EDIT_SNAP_SOURCE_ON", {"type": 'B', "value": 'PRESS'}, None))
|
||||
km_items_data["items"].append(("EDIT_SNAP_SOURCE_OFF", {"type": 'B', "value": 'PRESS'}, None))
|
||||
|
||||
if keyconfig_version <= (4, 1, 5):
|
||||
remove_properties({
|
||||
"transform.edge_slide": ["alt_navigation"],
|
||||
"transform.resize": ["alt_navigation"],
|
||||
"transform.rotate": ["alt_navigation"],
|
||||
"transform.shrink_fatten": ["alt_navigation"],
|
||||
"transform.transform": ["alt_navigation"],
|
||||
"transform.translate": ["alt_navigation"],
|
||||
"transform.vert_slide": ["alt_navigation"],
|
||||
"view3d.edit_mesh_extrude_move_normal": ["alt_navigation"],
|
||||
"armature.extrude_move": ["TRANSFORM_OT_translate"],
|
||||
"curve.extrude_move": ["TRANSFORM_OT_translate"],
|
||||
"gpencil.extrude_move": ["TRANSFORM_OT_translate"],
|
||||
"mesh.rip_edge_move": ["TRANSFORM_OT_translate"],
|
||||
"mesh.duplicate_move": ["TRANSFORM_OT_translate"],
|
||||
"object.duplicate_move": ["TRANSFORM_OT_translate"],
|
||||
"object.duplicate_move_linked": ["TRANSFORM_OT_translate"],
|
||||
})
|
||||
|
||||
if km_items_data := get_transform_modal_map():
|
||||
def use_alt_navigate():
|
||||
km_item = next(
|
||||
(i for i in km_items_data["items"] if i[0] ==
|
||||
"PROPORTIONAL_SIZE" and i[1]["type"] == 'TRACKPADPAN'),
|
||||
None,
|
||||
)
|
||||
if km_item:
|
||||
return "alt" not in km_item[1] or km_item[1]["alt"] is False
|
||||
|
||||
# Fallback.
|
||||
import bpy
|
||||
return getattr(
|
||||
bpy.context.window_manager.keyconfigs.active.preferences,
|
||||
"use_alt_navigation",
|
||||
False)
|
||||
|
||||
if use_alt_navigate():
|
||||
if not has_copy:
|
||||
keyconfig_data = copy.deepcopy(keyconfig_data)
|
||||
has_copy = True
|
||||
km_items_data = get_transform_modal_map()
|
||||
|
||||
km_items_data["items"].append(
|
||||
("PASSTHROUGH_NAVIGATE", {"type": 'LEFT_ALT', "value": 'ANY', "any": True}, None))
|
||||
|
||||
if keyconfig_version <= (4, 1, 21):
|
||||
rename_keymap({"NLA Channels": "NLA Tracks"})
|
||||
|
||||
if keyconfig_version <= (4, 5, 10):
|
||||
rename_keymap({"SequencerCommon": "Video Sequence Editor"})
|
||||
rename_keymap({"SequencerPreview": "Preview"})
|
||||
mappings = [
|
||||
("Sequencer Timeline Tool: Select Box", "Sequencer Tool: Select Box"),
|
||||
("Sequencer Preview Tool: Tweak", "Preview Tool: Tweak"),
|
||||
("Sequencer Preview Tool: Select Box", "Preview Tool: Select Box"),
|
||||
]
|
||||
for old, new in mappings:
|
||||
rename_keymap({old: new})
|
||||
rename_keymap({f"{old} (fallback)": f"{new} (fallback)"})
|
||||
rename_keymap({"Sequencer Tool: Cursor": "Preview Tool: Cursor"})
|
||||
rename_keymap({"Sequencer Tool: Sample": "Preview Tool: Sample"})
|
||||
rename_keymap({"Sequencer Tool: Move": "Preview Tool: Move"})
|
||||
rename_keymap({"Sequencer Tool: Rotate": "Preview Tool: Rotate"})
|
||||
rename_keymap({"Sequencer Tool: Scale": "Preview Tool: Scale"})
|
||||
|
||||
if keyconfig_version < (5, 0, 53):
|
||||
if not has_copy:
|
||||
keyconfig_data = copy.deepcopy(keyconfig_data)
|
||||
has_copy = True
|
||||
|
||||
# The `unified_paint_setting` struct was moved from `tool_settings` to be a sub-property of a given individual
|
||||
# paint type.
|
||||
#
|
||||
# The following conversion maps from the old values of
|
||||
# `tool_settings.unified_paint_settings.<property_name>`
|
||||
# to
|
||||
# `tool_settings.<paint_mode>.unified_paint_settings.<property_name>`
|
||||
# where <paint_mode> is retrieved from the `data_path_primary` property
|
||||
#
|
||||
# Example:
|
||||
# `tool_settings.unified_paint_settings.size`
|
||||
# and
|
||||
# `tool_settings.unified_paint_settings.use_unified_size`
|
||||
# for an operator with
|
||||
# `tool_settings.sculpt.brush.size`
|
||||
# become
|
||||
# `tool_settings.sculpt.unified_paint_settings.size`
|
||||
# and
|
||||
# `tool_settings.sculpt.unified_paint_settings.use_unified_size`
|
||||
|
||||
# Match paths of the form 'tool_settings.<paint_mode>.brush.<remaining_path>'
|
||||
re_toolsetting_brush = re.compile(r"^(tool_settings)\.([a-z_]+)\.(brush)\.(.*)")
|
||||
|
||||
for _km_name, _km_parms, km_items_data in keyconfig_data:
|
||||
for (item_op, _item_event, item_prop) in km_items_data["items"]:
|
||||
if item_op == "wm.radial_control":
|
||||
updated_path_elements = []
|
||||
secondary_path_index = -1
|
||||
secondary_path_identifier = ""
|
||||
toggle_path_index = -1
|
||||
toggle_path_identifier = ""
|
||||
|
||||
for prop_index, (prop_id, prop_path) in enumerate(item_prop["properties"]):
|
||||
if prop_id == "data_path_primary":
|
||||
if re_toolsetting_brush.fullmatch(prop_path):
|
||||
# Example:
|
||||
# 'tool_settings.sculpt.brush.size'
|
||||
# results in
|
||||
# ['tool_settings', 'sculpt', 'unified_paint_settings']
|
||||
updated_path_elements = prop_path.split(".")[0:2]
|
||||
updated_path_elements.append("unified_paint_settings")
|
||||
elif prop_id == "data_path_secondary":
|
||||
if prop_path.startswith("tool_settings.unified_paint_settings."):
|
||||
# Example:
|
||||
# 'tool_settings.unified_paint_settings.size'
|
||||
# results in
|
||||
# 'size'
|
||||
secondary_path_index = prop_index
|
||||
secondary_path_identifier = prop_path.split(".", 2)[-1]
|
||||
elif prop_id == "use_secondary":
|
||||
if prop_path.startswith("tool_settings.unified_paint_settings."):
|
||||
# Example:
|
||||
# 'tool_settings.unified_paint_settings.use_unified_size'
|
||||
# results in
|
||||
# 'use_unified_size'
|
||||
toggle_path_index = prop_index
|
||||
toggle_path_identifier = prop_path.split(".", 2)[-1]
|
||||
|
||||
if updated_path_elements and secondary_path_index != -1 and toggle_path_index != -1:
|
||||
item_prop["properties"][secondary_path_index] = (
|
||||
"data_path_secondary", ".".join((*updated_path_elements, secondary_path_identifier)))
|
||||
item_prop["properties"][toggle_path_index] = (
|
||||
"use_secondary", ".".join((*updated_path_elements, toggle_path_identifier)))
|
||||
|
||||
if keyconfig_version < (5, 1, 6):
|
||||
has_view_select = False
|
||||
has_view_scroll = False
|
||||
|
||||
if km_ui_items_data := get_ui_keymap():
|
||||
for (item_op, _item_event, _item_prop) in km_ui_items_data["items"]:
|
||||
if item_op == "ui.view_item_select":
|
||||
has_view_select = True
|
||||
if item_op == "ui.view_scroll":
|
||||
has_view_scroll = True
|
||||
|
||||
if not has_view_select:
|
||||
if not has_copy:
|
||||
keyconfig_data = copy.deepcopy(keyconfig_data)
|
||||
has_copy = True
|
||||
km_ui_items_data = get_ui_keymap()
|
||||
|
||||
select_items = [
|
||||
("ui.view_item_select", {"type": 'LEFTMOUSE', "value": 'PRESS'}, None),
|
||||
("ui.view_item_select", {"type": 'LEFTMOUSE', "value": 'PRESS', "ctrl": True},
|
||||
{"properties": [("extend", True)]}),
|
||||
("ui.view_item_select", {"type": 'LEFTMOUSE', "value": 'PRESS', "shift": True},
|
||||
{"properties": [("range_select", True)]}),
|
||||
]
|
||||
|
||||
km_ui_items_data["items"].extend(select_items)
|
||||
|
||||
if not has_view_scroll:
|
||||
if not has_copy:
|
||||
keyconfig_data = copy.deepcopy(keyconfig_data)
|
||||
has_copy = True
|
||||
km_ui_items_data = get_ui_keymap()
|
||||
|
||||
scroll_items = [
|
||||
("ui.view_scroll", {"type": 'WHEELUPMOUSE', "value": 'ANY'}, None),
|
||||
("ui.view_scroll", {"type": 'WHEELDOWNMOUSE', "value": 'ANY'}, None),
|
||||
("ui.view_scroll", {"type": 'TRACKPADPAN', "value": 'ANY'}, None),
|
||||
]
|
||||
km_ui_items_data["items"].extend(scroll_items)
|
||||
else:
|
||||
print("Error versioning keymap: Missing \"User Interface\" keymap")
|
||||
|
||||
if keyconfig_version < (5, 1, 11):
|
||||
rename_keymap({"Grease Pencil Paint Mode": "Grease Pencil Draw Mode"})
|
||||
|
||||
if keyconfig_version < (5, 2, 21):
|
||||
if not has_copy:
|
||||
keyconfig_data = copy.deepcopy(keyconfig_data)
|
||||
has_copy = True
|
||||
|
||||
for _km_name, _km_parms, km_items_data in keyconfig_data:
|
||||
for (item_op, _item_event, item_prop) in km_items_data["items"]:
|
||||
if item_op in {
|
||||
"grease_pencil.brush_stroke",
|
||||
"grease_pencil.sculpt_paint",
|
||||
"paint.image_paint",
|
||||
"paint.vertex_paint",
|
||||
"paint.weight_paint",
|
||||
"sculpt.brush_stroke",
|
||||
"sculpt_curves.brush_stroke",
|
||||
} and item_prop:
|
||||
index_to_fix = -1
|
||||
value_to_copy = None
|
||||
for prop_index, (prop_id, prop_value) in enumerate(item_prop["properties"]):
|
||||
if prop_id == "mode" and prop_value != 'INVERT':
|
||||
# The 'INVERT' value does not need to be migrated, as it is still a valid enum value
|
||||
index_to_fix = prop_index
|
||||
value_to_copy = prop_value
|
||||
break
|
||||
if index_to_fix != -1:
|
||||
item_prop["properties"][index_to_fix] = ("brush_toggle", value_to_copy)
|
||||
|
||||
return keyconfig_data
|
||||
123
blender-5.2.0/scripts/modules/blend_render_info.py
Executable file
123
blender-5.2.0/scripts/modules/blend_render_info.py
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# This module can get render info without running from inside blender.
|
||||
|
||||
|
||||
__all__ = (
|
||||
"read_blend_rend_chunk",
|
||||
)
|
||||
|
||||
import _blendfile_header
|
||||
|
||||
|
||||
class RawBlendFileReader:
|
||||
"""
|
||||
Return a file handle to the raw blend file data (abstracting compressed formats).
|
||||
"""
|
||||
__slots__ = (
|
||||
# The path to load.
|
||||
"_filepath",
|
||||
# The file base file handler or None (only set for compressed formats).
|
||||
"_blendfile_base",
|
||||
# The file handler to return to the caller (always uncompressed data).
|
||||
"_blendfile",
|
||||
)
|
||||
|
||||
def __init__(self, filepath):
|
||||
self._filepath = filepath
|
||||
self._blendfile_base = None
|
||||
self._blendfile = None
|
||||
|
||||
def __enter__(self):
|
||||
blendfile = open(self._filepath, "rb")
|
||||
blendfile_base = None
|
||||
head = blendfile.read(4)
|
||||
blendfile.seek(0)
|
||||
if head[0:2] == b'\x1f\x8b': # GZIP magic.
|
||||
import gzip
|
||||
blendfile_base = blendfile
|
||||
blendfile = gzip.open(blendfile, "rb")
|
||||
elif head[0:4] == b'\x28\xb5\x2f\xfd': # Z-standard magic.
|
||||
import zstandard
|
||||
blendfile_base = blendfile
|
||||
blendfile = zstandard.open(blendfile, "rb")
|
||||
|
||||
self._blendfile_base = blendfile_base
|
||||
self._blendfile = blendfile
|
||||
|
||||
return self._blendfile
|
||||
|
||||
def __exit__(self, _exc_type, _exc_value, _exc_traceback):
|
||||
self._blendfile.close()
|
||||
if self._blendfile_base is not None:
|
||||
self._blendfile_base.close()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_render_info_structure(endian_str, size):
|
||||
import struct
|
||||
# The maximum size of the scene name changed over time, so create a different
|
||||
# structure depending on the size of the entire block.
|
||||
if size == 2 * 4 + 24:
|
||||
return struct.Struct(endian_str + b'ii24s')
|
||||
if size == 2 * 4 + 64:
|
||||
return struct.Struct(endian_str + b'ii64s')
|
||||
if size == 2 * 4 + 256:
|
||||
return struct.Struct(endian_str + b'ii256s')
|
||||
raise ValueError("Unknown REND chunk size: {:d}".format(size))
|
||||
|
||||
|
||||
def _read_blend_rend_chunk_from_file(blendfile, filepath):
|
||||
import sys
|
||||
|
||||
from os import SEEK_CUR
|
||||
|
||||
try:
|
||||
blender_header = _blendfile_header.BlendFileHeader(blendfile)
|
||||
except _blendfile_header.BlendHeaderError:
|
||||
sys.stderr.write("Not a blend file: {:s}\n".format(filepath))
|
||||
return []
|
||||
|
||||
scenes = []
|
||||
|
||||
endian_str = b'<' if blender_header.is_little_endian else b'>'
|
||||
|
||||
block_header_struct = blender_header.create_block_header_struct()
|
||||
while bhead := _blendfile_header.BlockHeader(blendfile, block_header_struct):
|
||||
if bhead.code == b'ENDB':
|
||||
break
|
||||
remaining_bytes = bhead.size
|
||||
if bhead.code == b'REND':
|
||||
rend_block_struct = get_render_info_structure(endian_str, bhead.size)
|
||||
start_frame, end_frame, scene_name = rend_block_struct.unpack(blendfile.read(rend_block_struct.size))
|
||||
remaining_bytes -= rend_block_struct.size
|
||||
|
||||
scene_name = scene_name[:scene_name.index(b'\0')]
|
||||
# It's possible old blend files are not UTF8 compliant, use `surrogateescape`.
|
||||
scene_name = scene_name.decode("utf8", errors="surrogateescape")
|
||||
scenes.append((start_frame, end_frame, scene_name))
|
||||
|
||||
blendfile.seek(remaining_bytes, SEEK_CUR)
|
||||
|
||||
return scenes
|
||||
|
||||
|
||||
def read_blend_rend_chunk(filepath):
|
||||
with RawBlendFileReader(filepath) as blendfile:
|
||||
return _read_blend_rend_chunk_from_file(blendfile, filepath)
|
||||
|
||||
|
||||
def main():
|
||||
import sys
|
||||
|
||||
for filepath in sys.argv[1:]:
|
||||
for value in read_blend_rend_chunk(filepath):
|
||||
print("{:d} {:d} {:s}".format(*value))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
75
blender-5.2.0/scripts/modules/bpy/__init__.py
Normal file
75
blender-5.2.0/scripts/modules/bpy/__init__.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
Give access to blender data and utility functions.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"app",
|
||||
"context",
|
||||
"data",
|
||||
"msgbus",
|
||||
"ops",
|
||||
"path",
|
||||
"props",
|
||||
"types",
|
||||
"utils",
|
||||
)
|
||||
|
||||
|
||||
# Internal Blender C-API modules.
|
||||
from _bpy import (
|
||||
app,
|
||||
context,
|
||||
data,
|
||||
msgbus,
|
||||
props,
|
||||
types,
|
||||
)
|
||||
|
||||
# python modules
|
||||
from . import (
|
||||
ops,
|
||||
path,
|
||||
utils,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
import sys
|
||||
|
||||
# Possibly temp. addons path
|
||||
from os.path import join, dirname, exists
|
||||
|
||||
# It's unlikely this directory exists.
|
||||
# Keep it so users can bundle their own add-ons with app-templates which share modules.
|
||||
# Also keep this for consistency with the other `addons` directories.
|
||||
# Check this exists because the bundled scripts should not be manipulated at run-time.
|
||||
dirpath = join(dirname(dirname(dirname(__file__))), "addons_core", "modules")
|
||||
if exists(dirpath):
|
||||
sys.path.append(dirpath)
|
||||
|
||||
# Don't check if this exists as it may be created as part of installing add-ons.
|
||||
sys.path.append(join(utils.user_resource('SCRIPTS'), "addons", "modules"))
|
||||
|
||||
# fake module to allow:
|
||||
# from bpy.types import Panel
|
||||
sys.modules.update({
|
||||
"bpy.app": app,
|
||||
"bpy.app.handlers": app.handlers,
|
||||
"bpy.app.translations": app.translations,
|
||||
"bpy.types": types,
|
||||
})
|
||||
|
||||
# Initializes Python classes.
|
||||
# (good place to run a profiler or trace).
|
||||
# Postpone loading `extensions` scripts (add-ons & app-templates),
|
||||
# until after the key-maps have been initialized.
|
||||
utils.load_scripts(extensions=False)
|
||||
|
||||
|
||||
main()
|
||||
|
||||
del main
|
||||
65
blender-5.2.0/scripts/modules/bpy/ops.py
Normal file
65
blender-5.2.0/scripts/modules/bpy/ops.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# for slightly faster access
|
||||
from _bpy import ops as _ops_module
|
||||
|
||||
# op_add = _ops_module.add
|
||||
_op_dir = _ops_module.dir
|
||||
_op_create_function = _ops_module.create_function
|
||||
|
||||
_ModuleType = type(_ops_module)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Sub-Module Access
|
||||
|
||||
def _bpy_ops_submodule__getattr__(module, func):
|
||||
# Return a `BPyOpsCallable` object that bypasses Python `__call__` overhead
|
||||
# for improved operator execution performance.
|
||||
if func.startswith("__"):
|
||||
raise AttributeError(func)
|
||||
return _op_create_function(module, func)
|
||||
|
||||
|
||||
def _bpy_ops_submodule__dir__(module):
|
||||
functions = set()
|
||||
module_upper = module.upper()
|
||||
|
||||
for id_name in _op_dir():
|
||||
id_split = id_name.split("_OT_", 1)
|
||||
if len(id_split) == 2 and module_upper == id_split[0]:
|
||||
functions.add(id_split[1])
|
||||
|
||||
return list(functions)
|
||||
|
||||
|
||||
def _bpy_ops_submodule(module):
|
||||
result = _ModuleType("bpy.ops." + module)
|
||||
result.__getattr__ = lambda func: _bpy_ops_submodule__getattr__(module, func)
|
||||
result.__dir__ = lambda: _bpy_ops_submodule__dir__(module)
|
||||
return result
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Module Access
|
||||
|
||||
def __getattr__(module):
|
||||
# Return a value from `bpy.ops.{module}`.
|
||||
if module.startswith("__"):
|
||||
raise AttributeError(module)
|
||||
return _bpy_ops_submodule(module)
|
||||
|
||||
|
||||
def __dir__():
|
||||
submodules = set()
|
||||
for id_name in _op_dir():
|
||||
id_split = id_name.split("_OT_", 1)
|
||||
|
||||
if len(id_split) == 2:
|
||||
submodules.add(id_split[0].lower())
|
||||
else:
|
||||
submodules.add(id_split[0])
|
||||
|
||||
return list(submodules)
|
||||
465
blender-5.2.0/scripts/modules/bpy/path.py
Normal file
465
blender-5.2.0/scripts/modules/bpy/path.py
Normal file
@@ -0,0 +1,465 @@
|
||||
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This module has a similar scope to os.path, containing utility
|
||||
functions for dealing with paths in Blender.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"abspath",
|
||||
"basename",
|
||||
"clean_name",
|
||||
"display_name",
|
||||
"display_name_to_filepath",
|
||||
"display_name_from_filepath",
|
||||
"ensure_ext",
|
||||
"extensions_image",
|
||||
"extensions_movie",
|
||||
"extensions_audio",
|
||||
"is_subdir",
|
||||
"module_names",
|
||||
"native_pathsep",
|
||||
"reduce_dirs",
|
||||
"relpath",
|
||||
"resolve_ncase",
|
||||
)
|
||||
|
||||
import bpy as _bpy
|
||||
import os as _os
|
||||
|
||||
from _bpy_path import (
|
||||
extensions_audio,
|
||||
extensions_movie,
|
||||
extensions_image,
|
||||
)
|
||||
|
||||
|
||||
def _getattr_bytes(var, attr):
|
||||
return var.path_resolve(attr, False).as_bytes()
|
||||
|
||||
|
||||
def abspath(path, *, start=None, library=None):
|
||||
"""
|
||||
Returns the absolute path relative to the current blend file
|
||||
using the "//" prefix.
|
||||
|
||||
:param path: The path to convert to absolute.
|
||||
:type path: str | bytes
|
||||
:param start: Relative to this path,
|
||||
when not set the current filename is used.
|
||||
:type start: str | bytes | None
|
||||
:param library: The library this path is from. This is only included for
|
||||
convenience, when the library is not None its path replaces *start*.
|
||||
:type library: :class:`bpy.types.Library` | None
|
||||
:return: The absolute path.
|
||||
:rtype: str
|
||||
"""
|
||||
if isinstance(path, bytes):
|
||||
if path.startswith(b"//"):
|
||||
if library:
|
||||
start = _os.path.dirname(
|
||||
abspath(_getattr_bytes(library, "filepath")))
|
||||
return _os.path.join(
|
||||
_os.path.dirname(_getattr_bytes(_bpy.data, "filepath"))
|
||||
if start is None else start,
|
||||
path[2:],
|
||||
)
|
||||
else:
|
||||
if path.startswith("//"):
|
||||
if library:
|
||||
start = _os.path.dirname(
|
||||
abspath(library.filepath))
|
||||
return _os.path.join(
|
||||
_os.path.dirname(_bpy.data.filepath)
|
||||
if start is None else start,
|
||||
path[2:],
|
||||
)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def relpath(path, *, start=None):
|
||||
"""
|
||||
Returns the path relative to the current blend file using the "//" prefix.
|
||||
|
||||
:param path: An absolute path.
|
||||
:type path: str | bytes
|
||||
:param start: Relative to this path,
|
||||
when not set the current filename is used.
|
||||
:type start: str | bytes | None
|
||||
:return: The relative path.
|
||||
:rtype: str
|
||||
"""
|
||||
if isinstance(path, bytes):
|
||||
if not path.startswith(b"//"):
|
||||
if start is None:
|
||||
start = _os.path.dirname(_getattr_bytes(_bpy.data, "filepath"))
|
||||
return b"//" + _os.path.relpath(path, start)
|
||||
else:
|
||||
if not path.startswith("//"):
|
||||
if start is None:
|
||||
start = _os.path.dirname(_bpy.data.filepath)
|
||||
return "//" + _os.path.relpath(path, start)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def is_subdir(path, directory):
|
||||
"""
|
||||
Returns true if *path* is in a subdirectory of *directory*.
|
||||
Both paths must be absolute.
|
||||
|
||||
:param path: An absolute path.
|
||||
:type path: str | bytes
|
||||
:param directory: The parent directory to check against.
|
||||
:type directory: str | bytes
|
||||
:return: Whether or not the path is a subdirectory.
|
||||
:rtype: bool
|
||||
"""
|
||||
from os.path import normpath, normcase, sep
|
||||
path = normpath(normcase(path))
|
||||
directory = normpath(normcase(directory))
|
||||
if len(path) > len(directory):
|
||||
sep = sep.encode('ascii') if isinstance(directory, bytes) else sep
|
||||
if path.startswith(directory.rstrip(sep) + sep):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def clean_name(name, *, replace="_"):
|
||||
"""
|
||||
Returns a name with characters replaced that
|
||||
may cause problems under various circumstances,
|
||||
such as writing to a file.
|
||||
|
||||
All characters besides A-Z/a-z, 0-9 are replaced with "_"
|
||||
or the *replace* argument if defined.
|
||||
|
||||
:param name: The path name.
|
||||
:type name: str | bytes
|
||||
:param replace: The replacement for non-valid characters.
|
||||
:type replace: str
|
||||
:return: The cleaned name.
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
if replace != "_":
|
||||
if len(replace) != 1 or ord(replace) > 255:
|
||||
raise ValueError("Value must be a single ascii character")
|
||||
|
||||
def maketrans_init():
|
||||
trans_cache = clean_name._trans_cache
|
||||
trans = trans_cache.get(replace)
|
||||
if trans is None:
|
||||
bad_chars = (
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
|
||||
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
|
||||
0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
|
||||
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
|
||||
0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2e, 0x2f, 0x3a,
|
||||
0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 0x5b, 0x5c,
|
||||
0x5d, 0x5e, 0x60, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f,
|
||||
0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87,
|
||||
0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f,
|
||||
0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97,
|
||||
0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f,
|
||||
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7,
|
||||
0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf,
|
||||
0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7,
|
||||
0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf,
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7,
|
||||
0xc8, 0xc9, 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf,
|
||||
0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7,
|
||||
0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf,
|
||||
0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7,
|
||||
0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef,
|
||||
0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7,
|
||||
0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe,
|
||||
)
|
||||
trans = str.maketrans({char: replace for char in bad_chars})
|
||||
trans_cache[replace] = trans
|
||||
return trans
|
||||
|
||||
trans = maketrans_init()
|
||||
return name.translate(trans)
|
||||
|
||||
|
||||
clean_name._trans_cache = {}
|
||||
|
||||
|
||||
def _clean_utf8(name):
|
||||
if type(name) is bytes:
|
||||
return name.decode("utf8", "replace")
|
||||
else:
|
||||
return name.encode("utf8", "replace").decode("utf8")
|
||||
|
||||
|
||||
_display_name_literals = {
|
||||
":": "_colon_",
|
||||
"+": "_plus_",
|
||||
"/": "_slash_",
|
||||
}
|
||||
|
||||
|
||||
def display_name(name, *, has_ext=True, title_case=True):
|
||||
"""
|
||||
Creates a display string from name to be used in menus and the user interface.
|
||||
Intended for use with filenames and module names.
|
||||
|
||||
:param name: The name to be used for displaying the user interface.
|
||||
:type name: str
|
||||
:param has_ext: Remove file extension from name.
|
||||
:type has_ext: bool
|
||||
:param title_case: Convert lowercase names to title case.
|
||||
:type title_case: bool
|
||||
:return: The display string.
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
if has_ext:
|
||||
name = _os.path.splitext(basename(name))[0]
|
||||
|
||||
# string replacements
|
||||
for disp_value, file_value in _display_name_literals.items():
|
||||
name = name.replace(file_value, disp_value)
|
||||
|
||||
# strip to allow underscore prefix
|
||||
# (when paths can't start with numbers for eg).
|
||||
name = name.replace("_", " ").lstrip(" ")
|
||||
|
||||
if title_case and name.islower():
|
||||
name = name.lower().title()
|
||||
|
||||
name = _clean_utf8(name)
|
||||
return name
|
||||
|
||||
|
||||
def display_name_to_filepath(name):
|
||||
"""
|
||||
Performs the reverse of display_name using literal versions of characters
|
||||
which aren't supported in a filepath.
|
||||
|
||||
:param name: The display name to convert.
|
||||
:type name: str
|
||||
:return: The file path.
|
||||
:rtype: str
|
||||
"""
|
||||
for disp_value, file_value in _display_name_literals.items():
|
||||
name = name.replace(disp_value, file_value)
|
||||
return name
|
||||
|
||||
|
||||
def display_name_from_filepath(name):
|
||||
"""
|
||||
Returns the path stripped of directory and extension,
|
||||
ensured to be UTF-8 compatible.
|
||||
|
||||
:param name: The file path to convert.
|
||||
:type name: str
|
||||
:return: The display name.
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
name = _os.path.splitext(basename(name))[0]
|
||||
name = _clean_utf8(name)
|
||||
return name
|
||||
|
||||
|
||||
def resolve_ncase(path):
|
||||
"""
|
||||
Resolve a case insensitive path on a case sensitive system,
|
||||
returning a string with the path if found else return the original path.
|
||||
|
||||
:param path: The path name to resolve.
|
||||
:type path: str
|
||||
:return: The resolved path.
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
def _ncase_path_found(path):
|
||||
if not path or _os.path.exists(path):
|
||||
return path, True
|
||||
|
||||
# filename may be a directory or a file
|
||||
filename = _os.path.basename(path)
|
||||
dirpath = _os.path.dirname(path)
|
||||
|
||||
suffix = path[:0] # "" but ensure byte/str match
|
||||
if not filename: # Check if the directory ends with a slash.
|
||||
if len(dirpath) < len(path):
|
||||
suffix = path[:len(path) - len(dirpath)]
|
||||
|
||||
filename = _os.path.basename(dirpath)
|
||||
dirpath = _os.path.dirname(dirpath)
|
||||
|
||||
if not _os.path.exists(dirpath):
|
||||
if dirpath == path:
|
||||
return path, False
|
||||
|
||||
dirpath, found = _ncase_path_found(dirpath)
|
||||
|
||||
if not found:
|
||||
return path, False
|
||||
|
||||
# at this point, the directory exists but not the file
|
||||
|
||||
# we are expecting 'dirpath' to be a directory, but it could be a file
|
||||
if _os.path.isdir(dirpath):
|
||||
try:
|
||||
files = _os.listdir(dirpath)
|
||||
except PermissionError:
|
||||
# We might not have the permission to list dirpath...
|
||||
return path, False
|
||||
else:
|
||||
return path, False
|
||||
|
||||
filename_low = filename.lower()
|
||||
f_iter_nocase = None
|
||||
|
||||
for f_iter in files:
|
||||
if f_iter.lower() == filename_low:
|
||||
f_iter_nocase = f_iter
|
||||
break
|
||||
|
||||
if f_iter_nocase:
|
||||
return _os.path.join(dirpath, f_iter_nocase) + suffix, True
|
||||
else:
|
||||
# can't find the right one, just return the path as is.
|
||||
return path, False
|
||||
|
||||
ncase_path, found = _ncase_path_found(path)
|
||||
return ncase_path if found else path
|
||||
|
||||
|
||||
def ensure_ext(filepath, ext, *, case_sensitive=False):
|
||||
"""
|
||||
Return the path with the extension added if it is not already set.
|
||||
|
||||
:param filepath: The file path.
|
||||
:type filepath: str
|
||||
:param ext: The extension to check for, can be a compound extension. Should
|
||||
start with a dot, such as ``.blend`` or ``.tar.gz``.
|
||||
:type ext: str
|
||||
:param case_sensitive: Check for matching case when comparing extensions.
|
||||
:type case_sensitive: bool
|
||||
:return: The file path with the given extension.
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
if case_sensitive:
|
||||
if filepath.endswith(ext):
|
||||
return filepath
|
||||
else:
|
||||
if filepath[-len(ext):].lower().endswith(ext.lower()):
|
||||
return filepath
|
||||
|
||||
return filepath + ext
|
||||
|
||||
|
||||
def module_names(path, *, recursive=False, package=""):
|
||||
"""
|
||||
Return a list of modules which can be imported from *path*.
|
||||
|
||||
:param path: a directory to scan.
|
||||
:type path: str
|
||||
:param recursive: Also return submodule names for packages.
|
||||
:type recursive: bool
|
||||
:param package: Optional string, used as the prefix for module names (without the trailing ".").
|
||||
:type package: str
|
||||
:return: a list of string pairs (module_name, module_file).
|
||||
:rtype: list[tuple[str, str]]
|
||||
"""
|
||||
|
||||
from os.path import join, isfile
|
||||
|
||||
modules = []
|
||||
|
||||
package_prefix = (package + ".") if package else ""
|
||||
|
||||
for filename in sorted(_os.listdir(path)):
|
||||
if (filename == "modules") and (not package_prefix):
|
||||
pass # XXX, hard coded exception.
|
||||
elif filename.endswith(".py") and filename != "__init__.py":
|
||||
fullpath = join(path, filename)
|
||||
modules.append((package_prefix + filename[0:-3], fullpath))
|
||||
elif not filename.startswith("."):
|
||||
# Skip hidden files since they are used for version control.
|
||||
directory = join(path, filename)
|
||||
fullpath = join(directory, "__init__.py")
|
||||
if isfile(fullpath):
|
||||
modules.append((package_prefix + filename, fullpath))
|
||||
if recursive:
|
||||
for mod_name, mod_path in module_names(directory, recursive=True):
|
||||
modules.append((
|
||||
"{:s}.{:s}".format(package_prefix + filename, mod_name),
|
||||
mod_path,
|
||||
))
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
def basename(path):
|
||||
"""
|
||||
Equivalent to ``os.path.basename``, but skips a "//" prefix.
|
||||
|
||||
Use for Windows compatibility.
|
||||
|
||||
:param path: The path to get the base name of.
|
||||
:type path: str | bytes
|
||||
:return: The base name of the given path.
|
||||
:rtype: str
|
||||
"""
|
||||
return _os.path.basename(path[2:] if path[:2] in {"//", b"//"} else path)
|
||||
|
||||
|
||||
def native_pathsep(path):
|
||||
"""
|
||||
Replace the path separator with the system's native ``os.sep``.
|
||||
|
||||
:param path: The path to replace.
|
||||
:type path: str
|
||||
:return: The path with system native separators.
|
||||
:rtype: str
|
||||
"""
|
||||
if type(path) is str:
|
||||
if _os.sep == "/":
|
||||
return path.replace("\\", "/")
|
||||
else:
|
||||
if path.startswith("//"):
|
||||
return "//" + path[2:].replace("/", "\\")
|
||||
else:
|
||||
return path.replace("/", "\\")
|
||||
else: # bytes
|
||||
if _os.sep == "/":
|
||||
return path.replace(b"\\", b"/")
|
||||
else:
|
||||
if path.startswith(b"//"):
|
||||
return b"//" + path[2:].replace(b"/", b"\\")
|
||||
else:
|
||||
return path.replace(b"/", b"\\")
|
||||
|
||||
|
||||
def reduce_dirs(dirs):
|
||||
"""
|
||||
Given a sequence of directories, remove duplicates and
|
||||
any directories nested in one of the other paths.
|
||||
(Useful for recursive path searching).
|
||||
|
||||
:param dirs: Sequence of directory paths.
|
||||
:type dirs: Sequence[str]
|
||||
:return: A unique list of paths.
|
||||
:rtype: list[str]
|
||||
"""
|
||||
dirs = list({_os.path.normpath(_os.path.abspath(d)) for d in dirs})
|
||||
dirs.sort(key=lambda d: len(d))
|
||||
for i in range(len(dirs) - 1, -1, -1):
|
||||
for j in range(i):
|
||||
if len(dirs[i]) == len(dirs[j]):
|
||||
break
|
||||
elif is_subdir(dirs[i], dirs[j]):
|
||||
del dirs[i]
|
||||
break
|
||||
return dirs
|
||||
1455
blender-5.2.0/scripts/modules/bpy/utils/__init__.py
Normal file
1455
blender-5.2.0/scripts/modules/bpy/utils/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
136
blender-5.2.0/scripts/modules/bpy/utils/previews.py
Normal file
136
blender-5.2.0/scripts/modules/bpy/utils/previews.py
Normal file
@@ -0,0 +1,136 @@
|
||||
# SPDX-FileCopyrightText: 2015-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This module contains utility functions to handle custom previews.
|
||||
|
||||
It behaves as a high-level 'cached' previews manager.
|
||||
|
||||
This allows scripts to generate their own previews, and use them as icons in UI widgets
|
||||
('icon_value' for UILayout functions).
|
||||
|
||||
|
||||
Custom Icon Example
|
||||
-------------------
|
||||
|
||||
.. literalinclude:: __/__/__/scripts/templates_py/UI/previews_custom_icon.py
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"new",
|
||||
"remove",
|
||||
"ImagePreviewCollection",
|
||||
)
|
||||
|
||||
from _bpy import _utils_previews
|
||||
|
||||
_uuid_open = set()
|
||||
|
||||
|
||||
# High-level previews manager.
|
||||
# not accessed directly
|
||||
class ImagePreviewCollection(dict):
|
||||
"""
|
||||
Dictionary-like class of previews.
|
||||
|
||||
This is a subclass of Python's built-in dict type,
|
||||
used to store multiple image previews.
|
||||
|
||||
.. note::
|
||||
|
||||
- instance with :mod:`bpy.utils.previews.new`
|
||||
- keys must be ``str`` type.
|
||||
- values will be :class:`bpy.types.ImagePreview`
|
||||
"""
|
||||
|
||||
# Internal notes:
|
||||
# - Blender's internal 'PreviewImage' struct uses 'self._uuid' prefix.
|
||||
# - Blender's preview.new/load return the data if it exists,
|
||||
# don't do this for the Python API as it allows accidental re-use of names,
|
||||
# anyone who wants to reuse names can use dict.get() to check if it exists.
|
||||
# We could use this for the C API too (would need some investigation).
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._uuid = hex(id(self))
|
||||
_uuid_open.add(self._uuid)
|
||||
|
||||
def __del__(self):
|
||||
if self._uuid not in _uuid_open:
|
||||
return
|
||||
|
||||
raise ResourceWarning(
|
||||
"{!r}: left open, remove with 'bpy.utils.previews.remove()'".format(self)
|
||||
)
|
||||
self.close()
|
||||
|
||||
def _gen_key(self, name):
|
||||
return ":".join((self._uuid, name))
|
||||
|
||||
def new(self, name):
|
||||
if name in self:
|
||||
raise KeyError("key {!r} already exists".format(name))
|
||||
p = self[name] = _utils_previews.new(
|
||||
self._gen_key(name))
|
||||
return p
|
||||
new.__doc__ = _utils_previews.new.__doc__
|
||||
|
||||
def load(self, name, filepath, file_type, force_reload=False):
|
||||
if name in self:
|
||||
raise KeyError("key {!r} already exists".format(name))
|
||||
p = self[name] = _utils_previews.load(
|
||||
self._gen_key(name), filepath, file_type, force_reload)
|
||||
return p
|
||||
load.__doc__ = _utils_previews.load.__doc__
|
||||
|
||||
def clear(self):
|
||||
"""Clear all previews."""
|
||||
for name in self.keys():
|
||||
_utils_previews.release(self._gen_key(name))
|
||||
super().clear()
|
||||
|
||||
def close(self):
|
||||
"""Close the collection and clear all previews."""
|
||||
self.clear()
|
||||
_uuid_open.remove(self._uuid)
|
||||
|
||||
def __delitem__(self, key):
|
||||
_utils_previews.release(self._gen_key(key))
|
||||
super().__delitem__(key)
|
||||
|
||||
def __repr__(self):
|
||||
return "<{:s} id={:s}[{:d}], {!r}>".format(
|
||||
self.__class__.__name__, self._uuid, len(self), super()
|
||||
)
|
||||
|
||||
|
||||
def new():
|
||||
"""
|
||||
:return: a new preview collection.
|
||||
:rtype: :class:`ImagePreviewCollection`
|
||||
"""
|
||||
|
||||
return ImagePreviewCollection()
|
||||
|
||||
|
||||
def remove(pcoll):
|
||||
"""
|
||||
Remove the specified previews collection.
|
||||
|
||||
:param pcoll: Preview collection to close.
|
||||
:type pcoll: :class:`ImagePreviewCollection`
|
||||
"""
|
||||
pcoll.close()
|
||||
|
||||
|
||||
# don't complain about resources on exit (only unregister)
|
||||
import atexit
|
||||
|
||||
|
||||
def exit_clear_warning():
|
||||
del ImagePreviewCollection.__del__
|
||||
|
||||
|
||||
atexit.register(exit_clear_warning)
|
||||
del atexit, exit_clear_warning
|
||||
11
blender-5.2.0/scripts/modules/bpy/utils/toolsystem.py
Normal file
11
blender-5.2.0/scripts/modules/bpy/utils/toolsystem.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"ToolDef",
|
||||
)
|
||||
|
||||
# Until we untangle ToolDef from bl_ui internals,
|
||||
# use this module to document ToolDef.
|
||||
from bl_ui.space_toolsystem_common import ToolDef
|
||||
20
blender-5.2.0/scripts/modules/bpy_extras/__init__.py
Normal file
20
blender-5.2.0/scripts/modules/bpy_extras/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
Utility modules associated with the bpy module.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"anim_utils",
|
||||
"asset_utils",
|
||||
"object_utils",
|
||||
"io_utils",
|
||||
"image_utils",
|
||||
"keyconfig_utils",
|
||||
"mesh_utils",
|
||||
"node_utils",
|
||||
"view3d_utils",
|
||||
"id_map_utils",
|
||||
)
|
||||
1178
blender-5.2.0/scripts/modules/bpy_extras/anim_utils.py
Normal file
1178
blender-5.2.0/scripts/modules/bpy_extras/anim_utils.py
Normal file
File diff suppressed because it is too large
Load Diff
91
blender-5.2.0/scripts/modules/bpy_extras/asset_utils.py
Normal file
91
blender-5.2.0/scripts/modules/bpy_extras/asset_utils.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# SPDX-FileCopyrightText: 2020-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
Helpers for asset management tasks.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"AssetBrowserPanel",
|
||||
"AssetMetaDataPanel",
|
||||
"SpaceAssetInfo",
|
||||
)
|
||||
|
||||
|
||||
class SpaceAssetInfo:
|
||||
"""Utility class for checking if a space is an asset browser."""
|
||||
|
||||
@classmethod
|
||||
def is_asset_browser(cls, space_data):
|
||||
"""
|
||||
Check if the given space is an asset browser.
|
||||
|
||||
:param space_data: The space to check.
|
||||
:type space_data: :class:`bpy.types.Space`
|
||||
:return: True when the space is an asset browser.
|
||||
:rtype: bool
|
||||
"""
|
||||
return space_data and space_data.type == 'FILE_BROWSER' and space_data.browse_mode == 'ASSETS'
|
||||
|
||||
@classmethod
|
||||
def is_asset_browser_poll(cls, context):
|
||||
"""
|
||||
Poll whether the active space is an asset browser.
|
||||
|
||||
:param context: The context.
|
||||
:type context: :class:`bpy.types.Context`
|
||||
:return: True when the active space is an asset browser.
|
||||
:rtype: bool
|
||||
"""
|
||||
return cls.is_asset_browser(context.space_data)
|
||||
|
||||
|
||||
class AssetBrowserPanel:
|
||||
"""Mixin class for panels that should only show in the asset browser."""
|
||||
|
||||
bl_space_type = 'FILE_BROWSER'
|
||||
|
||||
@classmethod
|
||||
def asset_browser_panel_poll(cls, context):
|
||||
"""
|
||||
Check if the panel should be shown in the asset browser.
|
||||
|
||||
:param context: The context.
|
||||
:type context: :class:`bpy.types.Context`
|
||||
:return: True when the panel should be visible.
|
||||
:rtype: bool
|
||||
"""
|
||||
return SpaceAssetInfo.is_asset_browser_poll(context)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
"""
|
||||
Poll for asset browser visibility.
|
||||
|
||||
:param context: The context.
|
||||
:type context: :class:`bpy.types.Context`
|
||||
:return: True when the panel should be visible.
|
||||
:rtype: bool
|
||||
"""
|
||||
return cls.asset_browser_panel_poll(context)
|
||||
|
||||
|
||||
class AssetMetaDataPanel:
|
||||
"""Mixin class for panels that display asset metadata in the asset browser."""
|
||||
|
||||
bl_space_type = 'FILE_BROWSER'
|
||||
bl_region_type = 'TOOL_PROPS'
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
"""
|
||||
Poll for asset browser with active asset metadata.
|
||||
|
||||
:param context: The context.
|
||||
:type context: :class:`bpy.types.Context`
|
||||
:return: True when the asset browser has active asset data.
|
||||
:rtype: bool
|
||||
"""
|
||||
active_file = context.active_file
|
||||
return SpaceAssetInfo.is_asset_browser_poll(context) and active_file and active_file.asset_data
|
||||
56
blender-5.2.0/scripts/modules/bpy_extras/bmesh_utils.py
Normal file
56
blender-5.2.0/scripts/modules/bpy_extras/bmesh_utils.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"bmesh_linked_uv_islands",
|
||||
)
|
||||
|
||||
|
||||
def match_uv(face, vert, uv, uv_layer):
|
||||
for loop in face.loops:
|
||||
if loop.vert == vert:
|
||||
return uv == loop[uv_layer].uv
|
||||
return False
|
||||
|
||||
|
||||
def bmesh_linked_uv_islands(bm, uv_layer):
|
||||
"""
|
||||
Returns lists of faces connected by UV islands.
|
||||
|
||||
For meshes use :class:`bpy.types.Mesh.mesh_linked_uv_islands` instead.
|
||||
|
||||
:param bm: the bmesh used to group with.
|
||||
:type bmesh: :class:`BMesh`
|
||||
:param uv_layer: the UV layer to source UVs from.
|
||||
:type bmesh: :class:`BMLayerItem`
|
||||
:return: list of lists containing polygon indices
|
||||
:rtype: list[list[int]]
|
||||
"""
|
||||
|
||||
result = []
|
||||
used = set()
|
||||
for seed_face in bm.faces:
|
||||
if seed_face in used:
|
||||
continue # Face has already been processed.
|
||||
used.add(seed_face)
|
||||
island = [seed_face]
|
||||
stack = [seed_face] # Faces still to consider on this island.
|
||||
while stack:
|
||||
current_face = stack.pop()
|
||||
for loop in current_face.loops:
|
||||
v = loop.vert
|
||||
uv = loop[uv_layer].uv
|
||||
for f in v.link_faces:
|
||||
if f is current_face or f in used:
|
||||
continue
|
||||
if not match_uv(f, v, uv, uv_layer):
|
||||
continue
|
||||
|
||||
# `f` is part of island, add to island and stack
|
||||
used.add(f)
|
||||
island.append(f)
|
||||
stack.append(f)
|
||||
result.append(island)
|
||||
|
||||
return result
|
||||
53
blender-5.2.0/scripts/modules/bpy_extras/id_map_utils.py
Normal file
53
blender-5.2.0/scripts/modules/bpy_extras/id_map_utils.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
__all__ = (
|
||||
"get_id_reference_map",
|
||||
"get_all_referenced_ids",
|
||||
)
|
||||
|
||||
|
||||
def get_id_reference_map():
|
||||
"""Return a dictionary of direct data-block references for every data-block in the blend file.
|
||||
|
||||
:return: Each datablock of the .blend file mapped to the set of IDs they directly reference.
|
||||
:rtype: dict[bpy.types.ID, set[bpy.types.ID]]
|
||||
"""
|
||||
inv_map = {}
|
||||
for key, values in bpy.data.user_map().items():
|
||||
for value in values:
|
||||
if value == key:
|
||||
# So an object is not considered to be referencing itself.
|
||||
continue
|
||||
inv_map.setdefault(value, set()).add(key)
|
||||
return inv_map
|
||||
|
||||
|
||||
def get_all_referenced_ids(id, ref_map):
|
||||
"""
|
||||
Return a set of IDs directly or indirectly referenced by id.
|
||||
|
||||
:param id: Datablock whose references we're interested in.
|
||||
:type id: bpy.types.ID
|
||||
:param ref_map: The global ID reference map, retrieved from get_id_reference_map()
|
||||
:type ref_map: dict[bpy.types.ID, set[bpy.types.ID]]
|
||||
:return: Set of datablocks referenced by `id`.
|
||||
:rtype: set[bpy.types.ID]
|
||||
"""
|
||||
|
||||
def recursive_helper(ref_map, id, referenced_ids, visited):
|
||||
if id in visited:
|
||||
# Avoid infinite recursion from circular references.
|
||||
return
|
||||
visited.add(id)
|
||||
for ref in ref_map.get(id, []):
|
||||
referenced_ids.add(ref)
|
||||
recursive_helper(ref_map=ref_map, id=ref, referenced_ids=referenced_ids, visited=visited)
|
||||
|
||||
referenced_ids = set()
|
||||
recursive_helper(ref_map=ref_map, id=id, referenced_ids=referenced_ids, visited=set())
|
||||
return referenced_ids
|
||||
196
blender-5.2.0/scripts/modules/bpy_extras/image_utils.py
Normal file
196
blender-5.2.0/scripts/modules/bpy_extras/image_utils.py
Normal file
@@ -0,0 +1,196 @@
|
||||
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"load_image",
|
||||
)
|
||||
|
||||
|
||||
# limited replacement for BPyImage.comprehensiveImageLoad
|
||||
def load_image(
|
||||
imagepath,
|
||||
dirname="",
|
||||
place_holder=False,
|
||||
recursive=False,
|
||||
ncase_cmp=True,
|
||||
convert_callback=None,
|
||||
verbose=False,
|
||||
relpath=None,
|
||||
check_existing=False,
|
||||
force_reload=False,
|
||||
):
|
||||
"""
|
||||
Return an image from the file path with options to search multiple paths
|
||||
and return a placeholder if it's not found.
|
||||
|
||||
:param imagepath: The image filename
|
||||
If a path precedes it, this will be searched as well.
|
||||
:type imagepath: str
|
||||
:param dirname: is the directory where the image may be located - any file at
|
||||
the end will be ignored.
|
||||
:type dirname: str
|
||||
:param place_holder: if True a new place holder image will be created.
|
||||
this is useful so later you can relink the image to its original data.
|
||||
:type place_holder: bool
|
||||
:param recursive: If True, directories will be recursively searched.
|
||||
Be careful with this if you have files in your root directory because
|
||||
it may take a long time.
|
||||
:type recursive: bool
|
||||
:param ncase_cmp: on non windows systems, find the correct case for the file.
|
||||
:type ncase_cmp: bool
|
||||
:param convert_callback: a function that takes an existing path and returns
|
||||
a new one. Use this when loading image formats blender may not support,
|
||||
the CONVERT_CALLBACK can take the path for a GIF (for example),
|
||||
convert it to a PNG and return the PNG's path.
|
||||
For formats blender can read, simply return the path that is given.
|
||||
:type convert_callback: Callable[[str], str] | None
|
||||
:param verbose: If True, print extra information when searching for the image.
|
||||
:type verbose: bool
|
||||
:param relpath: If not None, make the file relative to this path.
|
||||
:type relpath: str | None
|
||||
:param check_existing: If true,
|
||||
returns already loaded image data-block if possible
|
||||
(based on file path).
|
||||
:type check_existing: bool
|
||||
:param force_reload: If true,
|
||||
force reloading of image (only useful when ``check_existing``
|
||||
is also enabled).
|
||||
:type force_reload: bool
|
||||
:return: an image or None
|
||||
:rtype: :class:`bpy.types.Image` | None
|
||||
"""
|
||||
import os
|
||||
import bpy
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Utility Functions
|
||||
|
||||
def _image_load_placeholder(path):
|
||||
name = path
|
||||
if type(path) is str:
|
||||
name = name.encode("utf-8", "replace")
|
||||
name = name.decode("utf-8", "replace")
|
||||
name = os.path.basename(name)
|
||||
|
||||
image = bpy.data.images.new(name, 128, 128)
|
||||
# allow the path to be resolved later
|
||||
image.filepath = path
|
||||
image.source = 'FILE'
|
||||
return image
|
||||
|
||||
def _image_load(path):
|
||||
import bpy
|
||||
|
||||
if convert_callback:
|
||||
path = convert_callback(path)
|
||||
|
||||
# Ensure we're not relying on the 'CWD' to resolve the path.
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.abspath(path)
|
||||
|
||||
try:
|
||||
image = bpy.data.images.load(path, check_existing=check_existing)
|
||||
except RuntimeError:
|
||||
image = None
|
||||
|
||||
if verbose:
|
||||
if image:
|
||||
print(" image loaded '{:s}'".format(path))
|
||||
else:
|
||||
print(" image load failed '{:s}'".format(path))
|
||||
|
||||
# image path has been checked so the path could not be read for some
|
||||
# reason, so be sure to return a placeholder
|
||||
if place_holder and image is None:
|
||||
image = _image_load_placeholder(path)
|
||||
|
||||
if image:
|
||||
if force_reload:
|
||||
image.reload()
|
||||
if relpath is not None:
|
||||
# make relative
|
||||
from bpy.path import relpath as relpath_fn
|
||||
# can't always find the relative path
|
||||
# (between drive letters on windows)
|
||||
try:
|
||||
filepath_rel = relpath_fn(path, start=relpath)
|
||||
except ValueError:
|
||||
filepath_rel = None
|
||||
|
||||
if filepath_rel is not None:
|
||||
image.filepath_raw = filepath_rel
|
||||
|
||||
return image
|
||||
|
||||
def _recursive_search(paths, filename_check):
|
||||
for path in paths:
|
||||
for dirpath, _dirnames, filenames in os.walk(path):
|
||||
|
||||
# skip '.svn'
|
||||
if dirpath[0] in {".", b'.'}:
|
||||
continue
|
||||
|
||||
for filename in filenames:
|
||||
if filename_check(filename):
|
||||
yield os.path.join(dirpath, filename)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
imagepath = bpy.path.native_pathsep(imagepath)
|
||||
|
||||
if verbose:
|
||||
print("load_image('{:s}', '{:s}', ...)".format(imagepath, dirname))
|
||||
|
||||
if os.path.exists(imagepath):
|
||||
return _image_load(imagepath)
|
||||
|
||||
variants = [imagepath]
|
||||
|
||||
if dirname:
|
||||
variants += [
|
||||
os.path.join(dirname, imagepath),
|
||||
os.path.join(dirname, bpy.path.basename(imagepath)),
|
||||
]
|
||||
|
||||
for filepath_test in variants:
|
||||
if ncase_cmp:
|
||||
ncase_variants = (
|
||||
filepath_test,
|
||||
bpy.path.resolve_ncase(filepath_test),
|
||||
)
|
||||
else:
|
||||
ncase_variants = (filepath_test, )
|
||||
|
||||
for nfilepath in ncase_variants:
|
||||
if os.path.exists(nfilepath):
|
||||
return _image_load(nfilepath)
|
||||
|
||||
if recursive:
|
||||
search_paths = []
|
||||
|
||||
for dirpath_test in (os.path.dirname(imagepath), dirname):
|
||||
if os.path.exists(dirpath_test):
|
||||
search_paths.append(dirpath_test)
|
||||
search_paths[:] = bpy.path.reduce_dirs(search_paths)
|
||||
|
||||
imagepath_base = bpy.path.basename(imagepath)
|
||||
if ncase_cmp:
|
||||
imagepath_base = imagepath_base.lower()
|
||||
|
||||
def image_filter(fn):
|
||||
return (imagepath_base == fn.lower())
|
||||
else:
|
||||
def image_filter(fn):
|
||||
return (imagepath_base == fn)
|
||||
|
||||
nfilepath = next(_recursive_search(search_paths, image_filter), None)
|
||||
if nfilepath is not None:
|
||||
return _image_load(nfilepath)
|
||||
|
||||
# None of the paths exist so return placeholder
|
||||
if place_holder:
|
||||
return _image_load_placeholder(imagepath)
|
||||
|
||||
# TODO comprehensiveImageLoad also searched in bpy.config.textureDir
|
||||
return None
|
||||
725
blender-5.2.0/scripts/modules/bpy_extras/io_utils.py
Normal file
725
blender-5.2.0/scripts/modules/bpy_extras/io_utils.py
Normal file
@@ -0,0 +1,725 @@
|
||||
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"ExportHelper",
|
||||
"ImportHelper",
|
||||
"orientation_helper",
|
||||
"axis_conversion",
|
||||
"axis_conversion_ensure",
|
||||
"create_derived_objects",
|
||||
"poll_file_object_drop",
|
||||
"unpack_list",
|
||||
"unpack_face_list",
|
||||
"path_reference",
|
||||
"path_reference_copy",
|
||||
"path_reference_mode",
|
||||
"unique_name",
|
||||
)
|
||||
|
||||
import bpy
|
||||
from bpy.props import (
|
||||
BoolProperty,
|
||||
EnumProperty,
|
||||
StringProperty,
|
||||
)
|
||||
from bpy.app.translations import (
|
||||
contexts as i18n_contexts,
|
||||
pgettext_iface as iface_,
|
||||
pgettext_data as data_,
|
||||
)
|
||||
|
||||
|
||||
def _check_axis_conversion(op):
|
||||
if hasattr(op, "axis_forward") and hasattr(op, "axis_up"):
|
||||
return axis_conversion_ensure(
|
||||
op,
|
||||
"axis_forward",
|
||||
"axis_up",
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class ExportHelper:
|
||||
filepath: StringProperty(
|
||||
name="File Path",
|
||||
description="Filepath used for exporting the file",
|
||||
maxlen=1024,
|
||||
subtype='FILE_PATH',
|
||||
)
|
||||
check_existing: BoolProperty(
|
||||
name="Check Existing",
|
||||
description="Check and warn on overwriting existing files",
|
||||
default=True,
|
||||
options={'HIDDEN'},
|
||||
)
|
||||
|
||||
# subclasses can override with decorator
|
||||
# True == use ext, False == no ext, None == do nothing.
|
||||
check_extension = True
|
||||
|
||||
def invoke(self, context, event):
|
||||
"""
|
||||
Invoke the file selector for exporting, setting a default filepath
|
||||
based on the current blend file name.
|
||||
|
||||
:param context: The context.
|
||||
:type context: :class:`bpy.types.Context`
|
||||
:param event: The window event.
|
||||
:type event: :class:`bpy.types.Event`
|
||||
:return: The operator return value.
|
||||
:rtype: set[str]
|
||||
"""
|
||||
del event
|
||||
import os
|
||||
if not self.filepath:
|
||||
blend_filepath = context.blend_data.filepath
|
||||
if not blend_filepath:
|
||||
blend_filepath = data_("Untitled")
|
||||
else:
|
||||
blend_filepath = os.path.splitext(blend_filepath)[0]
|
||||
|
||||
self.filepath = blend_filepath + self.filename_ext
|
||||
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def check(self, context):
|
||||
"""
|
||||
Validate the filepath and axis conversion settings.
|
||||
|
||||
:param context: The context.
|
||||
:type context: :class:`bpy.types.Context`
|
||||
:return: True when a property was updated.
|
||||
:rtype: bool
|
||||
"""
|
||||
del context
|
||||
import os
|
||||
change_ext = False
|
||||
change_axis = _check_axis_conversion(self)
|
||||
|
||||
check_extension = self.check_extension
|
||||
|
||||
if check_extension is not None:
|
||||
filepath = self.filepath
|
||||
if os.path.basename(filepath):
|
||||
if check_extension:
|
||||
filepath = bpy.path.ensure_ext(
|
||||
os.path.splitext(filepath)[0],
|
||||
self.filename_ext,
|
||||
)
|
||||
if filepath != self.filepath:
|
||||
self.filepath = filepath
|
||||
change_ext = True
|
||||
|
||||
return (change_ext or change_axis)
|
||||
|
||||
|
||||
class ImportHelper:
|
||||
filepath: StringProperty(
|
||||
name="File Path",
|
||||
description="Filepath used for importing the file",
|
||||
maxlen=1024,
|
||||
subtype='FILE_PATH',
|
||||
options={'SKIP_PRESET', 'HIDDEN'}
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
"""
|
||||
Invoke the file selector for importing.
|
||||
|
||||
:param context: The context.
|
||||
:type context: :class:`bpy.types.Context`
|
||||
:param event: The window event.
|
||||
:type event: :class:`bpy.types.Event`
|
||||
:return: The operator return value.
|
||||
:rtype: set[str]
|
||||
"""
|
||||
del event
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def invoke_popup(self, context, confirm_text=""):
|
||||
"""
|
||||
Invoke as a popup confirmation dialog when a filepath is already set,
|
||||
otherwise fall back to the file selector.
|
||||
|
||||
:param context: The context.
|
||||
:type context: :class:`bpy.types.Context`
|
||||
:param confirm_text: Label for the confirm button,
|
||||
defaults to the operator label.
|
||||
:type confirm_text: str
|
||||
:return: The operator return value.
|
||||
:rtype: set[str]
|
||||
"""
|
||||
if self.properties.is_property_set("filepath"):
|
||||
title = self.filepath
|
||||
if len(self.files) > 1:
|
||||
title = iface_("Import {:d} files").format(len(self.files))
|
||||
|
||||
if confirm_text:
|
||||
confirm_text = iface_(confirm_text)
|
||||
else:
|
||||
# Use the operator's bl_label, extracted with an "Operator" translation context.
|
||||
confirm_text = iface_(self.bl_label, i18n_contexts.operator_default)
|
||||
|
||||
return context.window_manager.invoke_props_dialog(
|
||||
self,
|
||||
confirm_text=confirm_text,
|
||||
title=title,
|
||||
translate=False,
|
||||
)
|
||||
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def check(self, context):
|
||||
"""
|
||||
Validate axis conversion settings.
|
||||
|
||||
:param context: The context.
|
||||
:type context: :class:`bpy.types.Context`
|
||||
:return: True when a property was updated.
|
||||
:rtype: bool
|
||||
"""
|
||||
del context
|
||||
return _check_axis_conversion(self)
|
||||
|
||||
|
||||
def orientation_helper(axis_forward='Y', axis_up='Z'):
|
||||
"""
|
||||
A decorator for import/export classes, generating properties needed by the axis conversion system and IO helpers,
|
||||
with specified default values (axes).
|
||||
|
||||
:param axis_forward: The default forward axis.
|
||||
:type axis_forward: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
|
||||
:param axis_up: The default up axis.
|
||||
:type axis_up: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
|
||||
:return: A class decorator.
|
||||
:rtype: Callable[[type], type]
|
||||
"""
|
||||
|
||||
def wrapper(cls):
|
||||
# Python 3.14+ (PEP 649): This workaround is no longer needed because annotations
|
||||
# are lazily evaluated. Accessing `cls.__annotations__` always returns a dict
|
||||
# specific to that class (never the parent's), so adding items is safe.
|
||||
import sys
|
||||
if sys.version_info < (3, 14):
|
||||
# Without this, we may end up adding those fields to some **parent** class'
|
||||
# `__annotations__` property (like the ImportHelper or ExportHelper ones)! See #58772.
|
||||
if "__annotations__" not in cls.__dict__:
|
||||
setattr(cls, "__annotations__", {})
|
||||
|
||||
def _update_axis_forward(self, _context):
|
||||
if self.axis_forward[-1] == self.axis_up[-1]:
|
||||
self.axis_up = (
|
||||
self.axis_up[0:-1] +
|
||||
'XYZ'[('XYZ'.index(self.axis_up[-1]) + 1) % 3]
|
||||
)
|
||||
|
||||
cls.__annotations__["axis_forward"] = EnumProperty(
|
||||
name="Forward",
|
||||
items=(
|
||||
('X', "X Forward", ""),
|
||||
('Y', "Y Forward", ""),
|
||||
('Z', "Z Forward", ""),
|
||||
('-X', "-X Forward", ""),
|
||||
('-Y', "-Y Forward", ""),
|
||||
('-Z', "-Z Forward", ""),
|
||||
),
|
||||
default=axis_forward,
|
||||
update=_update_axis_forward,
|
||||
)
|
||||
|
||||
def _update_axis_up(self, _context):
|
||||
if self.axis_up[-1] == self.axis_forward[-1]:
|
||||
self.axis_forward = (
|
||||
self.axis_forward[0:-1] +
|
||||
'XYZ'[('XYZ'.index(self.axis_forward[-1]) + 1) % 3]
|
||||
)
|
||||
|
||||
cls.__annotations__["axis_up"] = EnumProperty(
|
||||
name="Up",
|
||||
items=(
|
||||
('X', "X Up", ""),
|
||||
('Y', "Y Up", ""),
|
||||
('Z', "Z Up", ""),
|
||||
('-X', "-X Up", ""),
|
||||
('-Y', "-Y Up", ""),
|
||||
('-Z', "-Z Up", ""),
|
||||
),
|
||||
default=axis_up,
|
||||
update=_update_axis_up,
|
||||
)
|
||||
|
||||
return cls
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# Axis conversion function, not pretty LUT
|
||||
# use lookup table to convert between any axis
|
||||
_axis_convert_matrix = (
|
||||
((-1.0, 0.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, 1.0)),
|
||||
((-1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (0.0, -1.0, 0.0)),
|
||||
((-1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, 1.0, 0.0)),
|
||||
((-1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, -1.0)),
|
||||
((0.0, -1.0, 0.0), (-1.0, 0.0, 0.0), (0.0, 0.0, -1.0)),
|
||||
((0.0, 0.0, 1.0), (-1.0, 0.0, 0.0), (0.0, -1.0, 0.0)),
|
||||
((0.0, 0.0, -1.0), (-1.0, 0.0, 0.0), (0.0, 1.0, 0.0)),
|
||||
((0.0, 1.0, 0.0), (-1.0, 0.0, 0.0), (0.0, 0.0, 1.0)),
|
||||
((0.0, -1.0, 0.0), (0.0, 0.0, 1.0), (-1.0, 0.0, 0.0)),
|
||||
((0.0, 0.0, -1.0), (0.0, -1.0, 0.0), (-1.0, 0.0, 0.0)),
|
||||
((0.0, 0.0, 1.0), (0.0, 1.0, 0.0), (-1.0, 0.0, 0.0)),
|
||||
((0.0, 1.0, 0.0), (0.0, 0.0, -1.0), (-1.0, 0.0, 0.0)),
|
||||
((0.0, -1.0, 0.0), (0.0, 0.0, -1.0), (1.0, 0.0, 0.0)),
|
||||
((0.0, 0.0, 1.0), (0.0, -1.0, 0.0), (1.0, 0.0, 0.0)),
|
||||
((0.0, 0.0, -1.0), (0.0, 1.0, 0.0), (1.0, 0.0, 0.0)),
|
||||
((0.0, 1.0, 0.0), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0)),
|
||||
((0.0, -1.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0)),
|
||||
((0.0, 0.0, -1.0), (1.0, 0.0, 0.0), (0.0, -1.0, 0.0)),
|
||||
((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)),
|
||||
((0.0, 1.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, -1.0)),
|
||||
((1.0, 0.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, -1.0)),
|
||||
((1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, -1.0, 0.0)),
|
||||
((1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (0.0, 1.0, 0.0)),
|
||||
)
|
||||
|
||||
# store args as a single int
|
||||
# (X Y Z -X -Y -Z) --> (0, 1, 2, 3, 4, 5)
|
||||
# each value is ((src_forward, src_up), (dst_forward, dst_up))
|
||||
# where all 4 values are or'd into a single value...
|
||||
# (i1<<0 | i1<<3 | i1<<6 | i1<<9)
|
||||
_axis_convert_lut = (
|
||||
{0x8C8, 0x4D0, 0x2E0, 0xAE8, 0x701, 0x511, 0x119, 0xB29, 0x682, 0x88A,
|
||||
0x09A, 0x2A2, 0x80B, 0x413, 0x223, 0xA2B, 0x644, 0x454, 0x05C, 0xA6C,
|
||||
0x745, 0x94D, 0x15D, 0x365},
|
||||
{0xAC8, 0x8D0, 0x4E0, 0x2E8, 0x741, 0x951, 0x159, 0x369, 0x702, 0xB0A,
|
||||
0x11A, 0x522, 0xA0B, 0x813, 0x423, 0x22B, 0x684, 0x894, 0x09C, 0x2AC,
|
||||
0x645, 0xA4D, 0x05D, 0x465},
|
||||
{0x4C8, 0x2D0, 0xAE0, 0x8E8, 0x681, 0x291, 0x099, 0x8A9, 0x642, 0x44A,
|
||||
0x05A, 0xA62, 0x40B, 0x213, 0xA23, 0x82B, 0x744, 0x354, 0x15C, 0x96C,
|
||||
0x705, 0x50D, 0x11D, 0xB25},
|
||||
{0x2C8, 0xAD0, 0x8E0, 0x4E8, 0x641, 0xA51, 0x059, 0x469, 0x742, 0x34A,
|
||||
0x15A, 0x962, 0x20B, 0xA13, 0x823, 0x42B, 0x704, 0xB14, 0x11C, 0x52C,
|
||||
0x685, 0x28D, 0x09D, 0x8A5},
|
||||
{0x708, 0xB10, 0x120, 0x528, 0x8C1, 0xAD1, 0x2D9, 0x4E9, 0x942, 0x74A,
|
||||
0x35A, 0x162, 0x64B, 0xA53, 0x063, 0x46B, 0x804, 0xA14, 0x21C, 0x42C,
|
||||
0x885, 0x68D, 0x29D, 0x0A5},
|
||||
{0xB08, 0x110, 0x520, 0x728, 0x941, 0x151, 0x359, 0x769, 0x802, 0xA0A,
|
||||
0x21A, 0x422, 0xA4B, 0x053, 0x463, 0x66B, 0x884, 0x094, 0x29C, 0x6AC,
|
||||
0x8C5, 0xACD, 0x2DD, 0x4E5},
|
||||
{0x508, 0x710, 0xB20, 0x128, 0x881, 0x691, 0x299, 0x0A9, 0x8C2, 0x4CA,
|
||||
0x2DA, 0xAE2, 0x44B, 0x653, 0xA63, 0x06B, 0x944, 0x754, 0x35C, 0x16C,
|
||||
0x805, 0x40D, 0x21D, 0xA25},
|
||||
{0x108, 0x510, 0x720, 0xB28, 0x801, 0x411, 0x219, 0xA29, 0x882, 0x08A,
|
||||
0x29A, 0x6A2, 0x04B, 0x453, 0x663, 0xA6B, 0x8C4, 0x4D4, 0x2DC, 0xAEC,
|
||||
0x945, 0x14D, 0x35D, 0x765},
|
||||
{0x748, 0x350, 0x160, 0x968, 0xAC1, 0x2D1, 0x4D9, 0x8E9, 0xA42, 0x64A,
|
||||
0x45A, 0x062, 0x68B, 0x293, 0x0A3, 0x8AB, 0xA04, 0x214, 0x41C, 0x82C,
|
||||
0xB05, 0x70D, 0x51D, 0x125},
|
||||
{0x948, 0x750, 0x360, 0x168, 0xB01, 0x711, 0x519, 0x129, 0xAC2, 0x8CA,
|
||||
0x4DA, 0x2E2, 0x88B, 0x693, 0x2A3, 0x0AB, 0xA44, 0x654, 0x45C, 0x06C,
|
||||
0xA05, 0x80D, 0x41D, 0x225},
|
||||
{0x348, 0x150, 0x960, 0x768, 0xA41, 0x051, 0x459, 0x669, 0xA02, 0x20A,
|
||||
0x41A, 0x822, 0x28B, 0x093, 0x8A3, 0x6AB, 0xB04, 0x114, 0x51C, 0x72C,
|
||||
0xAC5, 0x2CD, 0x4DD, 0x8E5},
|
||||
{0x148, 0x950, 0x760, 0x368, 0xA01, 0x811, 0x419, 0x229, 0xB02, 0x10A,
|
||||
0x51A, 0x722, 0x08B, 0x893, 0x6A3, 0x2AB, 0xAC4, 0x8D4, 0x4DC, 0x2EC,
|
||||
0xA45, 0x04D, 0x45D, 0x665},
|
||||
{0x688, 0x890, 0x0A0, 0x2A8, 0x4C1, 0x8D1, 0xAD9, 0x2E9, 0x502, 0x70A,
|
||||
0xB1A, 0x122, 0x74B, 0x953, 0x163, 0x36B, 0x404, 0x814, 0xA1C, 0x22C,
|
||||
0x445, 0x64D, 0xA5D, 0x065},
|
||||
{0x888, 0x090, 0x2A0, 0x6A8, 0x501, 0x111, 0xB19, 0x729, 0x402, 0x80A,
|
||||
0xA1A, 0x222, 0x94B, 0x153, 0x363, 0x76B, 0x444, 0x054, 0xA5C, 0x66C,
|
||||
0x4C5, 0x8CD, 0xADD, 0x2E5},
|
||||
{0x288, 0x690, 0x8A0, 0x0A8, 0x441, 0x651, 0xA59, 0x069, 0x4C2, 0x2CA,
|
||||
0xADA, 0x8E2, 0x34B, 0x753, 0x963, 0x16B, 0x504, 0x714, 0xB1C, 0x12C,
|
||||
0x405, 0x20D, 0xA1D, 0x825},
|
||||
{0x088, 0x290, 0x6A0, 0x8A8, 0x401, 0x211, 0xA19, 0x829, 0x442, 0x04A,
|
||||
0xA5A, 0x662, 0x14B, 0x353, 0x763, 0x96B, 0x4C4, 0x2D4, 0xADC, 0x8EC,
|
||||
0x505, 0x10D, 0xB1D, 0x725},
|
||||
{0x648, 0x450, 0x060, 0xA68, 0x2C1, 0x4D1, 0x8D9, 0xAE9, 0x282, 0x68A,
|
||||
0x89A, 0x0A2, 0x70B, 0x513, 0x123, 0xB2B, 0x204, 0x414, 0x81C, 0xA2C,
|
||||
0x345, 0x74D, 0x95D, 0x165},
|
||||
{0xA48, 0x650, 0x460, 0x068, 0x341, 0x751, 0x959, 0x169, 0x2C2, 0xACA,
|
||||
0x8DA, 0x4E2, 0xB0B, 0x713, 0x523, 0x12B, 0x284, 0x694, 0x89C, 0x0AC,
|
||||
0x205, 0xA0D, 0x81D, 0x425},
|
||||
{0x448, 0x050, 0xA60, 0x668, 0x281, 0x091, 0x899, 0x6A9, 0x202, 0x40A,
|
||||
0x81A, 0xA22, 0x50B, 0x113, 0xB23, 0x72B, 0x344, 0x154, 0x95C, 0x76C,
|
||||
0x2C5, 0x4CD, 0x8DD, 0xAE5},
|
||||
{0x048, 0xA50, 0x660, 0x468, 0x201, 0xA11, 0x819, 0x429, 0x342, 0x14A,
|
||||
0x95A, 0x762, 0x10B, 0xB13, 0x723, 0x52B, 0x2C4, 0xAD4, 0x8DC, 0x4EC,
|
||||
0x285, 0x08D, 0x89D, 0x6A5},
|
||||
{0x808, 0xA10, 0x220, 0x428, 0x101, 0xB11, 0x719, 0x529, 0x142, 0x94A,
|
||||
0x75A, 0x362, 0x8CB, 0xAD3, 0x2E3, 0x4EB, 0x044, 0xA54, 0x65C, 0x46C,
|
||||
0x085, 0x88D, 0x69D, 0x2A5},
|
||||
{0xA08, 0x210, 0x420, 0x828, 0x141, 0x351, 0x759, 0x969, 0x042, 0xA4A,
|
||||
0x65A, 0x462, 0xACB, 0x2D3, 0x4E3, 0x8EB, 0x084, 0x294, 0x69C, 0x8AC,
|
||||
0x105, 0xB0D, 0x71D, 0x525},
|
||||
{0x408, 0x810, 0xA20, 0x228, 0x081, 0x891, 0x699, 0x2A9, 0x102, 0x50A,
|
||||
0x71A, 0xB22, 0x4CB, 0x8D3, 0xAE3, 0x2EB, 0x144, 0x954, 0x75C, 0x36C,
|
||||
0x045, 0x44D, 0x65D, 0xA65},
|
||||
)
|
||||
|
||||
_axis_convert_num = {'X': 0, 'Y': 1, 'Z': 2, '-X': 3, '-Y': 4, '-Z': 5}
|
||||
|
||||
|
||||
def axis_conversion(from_forward='Y', from_up='Z', to_forward='Y', to_up='Z'):
|
||||
"""
|
||||
Each argument is an axis
|
||||
where the first 2 are a source and the second 2 are the target.
|
||||
|
||||
:param from_forward: Source forward axis.
|
||||
:type from_forward: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
|
||||
:param from_up: Source up axis.
|
||||
:type from_up: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
|
||||
:param to_forward: Target forward axis.
|
||||
:type to_forward: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
|
||||
:param to_up: Target up axis.
|
||||
:type to_up: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
|
||||
:return: The conversion matrix.
|
||||
:rtype: :class:`mathutils.Matrix`
|
||||
"""
|
||||
from mathutils import Matrix
|
||||
from functools import reduce
|
||||
|
||||
if from_forward == to_forward and from_up == to_up:
|
||||
return Matrix().to_3x3()
|
||||
|
||||
if from_forward[-1] == from_up[-1] or to_forward[-1] == to_up[-1]:
|
||||
raise Exception("Invalid axis arguments passed, cannot use up/forward on the same axis")
|
||||
|
||||
value = reduce(
|
||||
int.__or__,
|
||||
(_axis_convert_num[a] << (i * 3) for i, a in enumerate((
|
||||
from_forward,
|
||||
from_up,
|
||||
to_forward,
|
||||
to_up,
|
||||
)))
|
||||
)
|
||||
|
||||
for i, axis_lut in enumerate(_axis_convert_lut):
|
||||
if value in axis_lut:
|
||||
return Matrix(_axis_convert_matrix[i])
|
||||
assert False, "unreachable"
|
||||
|
||||
|
||||
def axis_conversion_ensure(operator, forward_attr, up_attr):
|
||||
"""
|
||||
Function to ensure an operator has valid axis conversion settings, intended
|
||||
to be used from :class:`bpy.types.Operator.check`.
|
||||
|
||||
:param operator: the operator to access axis attributes from.
|
||||
:type operator: :class:`bpy.types.Operator`
|
||||
:param forward_attr: attribute storing the forward axis
|
||||
:type forward_attr: str
|
||||
:param up_attr: attribute storing the up axis
|
||||
:type up_attr: str
|
||||
:return: True if the value was modified.
|
||||
:rtype: bool
|
||||
"""
|
||||
def validate(axis_forward, axis_up):
|
||||
if axis_forward[-1] == axis_up[-1]:
|
||||
axis_up = axis_up[0:-1] + 'XYZ'[('XYZ'.index(axis_up[-1]) + 1) % 3]
|
||||
|
||||
return axis_forward, axis_up
|
||||
|
||||
axis = getattr(operator, forward_attr), getattr(operator, up_attr)
|
||||
axis_new = validate(*axis)
|
||||
|
||||
if axis != axis_new:
|
||||
setattr(operator, forward_attr, axis_new[0])
|
||||
setattr(operator, up_attr, axis_new[1])
|
||||
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def create_derived_objects(depsgraph, objects):
|
||||
"""
|
||||
This function takes a sequence of objects, returning their instances.
|
||||
|
||||
:param depsgraph: The evaluated depsgraph.
|
||||
:type depsgraph: :class:`bpy.types.Depsgraph`
|
||||
:param objects: A sequence of objects.
|
||||
:type objects: Sequence[:class:`bpy.types.Object`]
|
||||
:return: A dictionary where each key is an object from ``objects``,
|
||||
values are lists of (object, matrix) tuples representing instances.
|
||||
:rtype: dict[:class:`bpy.types.Object`, list[tuple[:class:`bpy.types.Object`, :class:`mathutils.Matrix`]]]
|
||||
"""
|
||||
result = {}
|
||||
for ob in objects:
|
||||
ob_parent = ob.parent
|
||||
if ob_parent and ob_parent.instance_type in {'VERTS', 'FACES'}:
|
||||
continue
|
||||
result[ob] = [] if ob.is_instancer else [(ob, ob.matrix_world.copy())]
|
||||
|
||||
if result:
|
||||
for dup in depsgraph.object_instances:
|
||||
dup_parent = dup.parent
|
||||
if dup_parent is None:
|
||||
continue
|
||||
dup_parent_original = dup_parent.original
|
||||
if not dup_parent_original.is_instancer:
|
||||
# The instance has already been added (on assignment).
|
||||
continue
|
||||
instance_list = result.get(dup_parent_original)
|
||||
if instance_list is None:
|
||||
continue
|
||||
instance_list.append((dup.instance_object.original, dup.matrix_world.copy()))
|
||||
return result
|
||||
|
||||
|
||||
def unpack_list(list_of_tuples):
|
||||
"""
|
||||
Flatten a sequence of tuples into a single list.
|
||||
|
||||
:param list_of_tuples: A sequence of tuples to unpack.
|
||||
:type list_of_tuples: Sequence[tuple]
|
||||
:return: A flat list of all values.
|
||||
:rtype: list
|
||||
"""
|
||||
flat_list = []
|
||||
flat_list_extend = flat_list.extend # a tiny bit faster
|
||||
for t in list_of_tuples:
|
||||
flat_list_extend(t)
|
||||
return flat_list
|
||||
|
||||
|
||||
# same as above except that it adds 0 for triangle faces
|
||||
def unpack_face_list(list_of_tuples):
|
||||
"""
|
||||
Unpack a list of faces (triangles or quads) into a flat list,
|
||||
padding triangles with a zero to fit into groups of four.
|
||||
|
||||
:param list_of_tuples: A sequence of face index tuples (3 or 4 elements each).
|
||||
:type list_of_tuples: Sequence[tuple[int, ...]]
|
||||
:return: A flat list of face indices, padded with zeros.
|
||||
:rtype: list[int]
|
||||
"""
|
||||
# allocate the entire list
|
||||
flat_ls = [0] * (len(list_of_tuples) * 4)
|
||||
i = 0
|
||||
|
||||
for t in list_of_tuples:
|
||||
if len(t) == 3:
|
||||
if t[2] == 0:
|
||||
t = t[1], t[2], t[0]
|
||||
else: # assume quad
|
||||
if t[3] == 0 or t[2] == 0:
|
||||
t = t[2], t[3], t[0], t[1]
|
||||
|
||||
flat_ls[i:i + len(t)] = t
|
||||
i += 4
|
||||
return flat_ls
|
||||
|
||||
|
||||
def poll_file_object_drop(context):
|
||||
"""
|
||||
A default implementation for FileHandler poll_drop methods. Allows for both the 3D Viewport and
|
||||
the Outliner (in ViewLayer display mode) to be targets for file drag and drop.
|
||||
|
||||
:param context: The context.
|
||||
:type context: :class:`bpy.types.Context`
|
||||
:return: Whether the drop target is valid.
|
||||
:rtype: bool
|
||||
"""
|
||||
area = context.area
|
||||
if not area:
|
||||
return False
|
||||
is_v3d = area.type == 'VIEW_3D'
|
||||
is_outliner_view_layer = area.type == 'OUTLINER' and area.spaces.active.display_mode == 'VIEW_LAYER'
|
||||
return is_v3d or is_outliner_view_layer
|
||||
|
||||
|
||||
path_reference_mode = EnumProperty(
|
||||
name="Path Mode",
|
||||
description="Method used to reference paths",
|
||||
items=(
|
||||
('AUTO', "Auto", "Use relative paths with subdirectories only"),
|
||||
('ABSOLUTE', "Absolute", "Always write absolute paths"),
|
||||
('RELATIVE', "Relative", "Write relative paths where possible"),
|
||||
('MATCH', "Match", "Match absolute/relative "
|
||||
"setting with input path"),
|
||||
('STRIP', "Strip", "Filename only"),
|
||||
('COPY', "Copy", "Copy the file to the destination path "
|
||||
"(or subdirectory)"),
|
||||
),
|
||||
translation_context=i18n_contexts.editor_filebrowser,
|
||||
default='AUTO',
|
||||
)
|
||||
|
||||
|
||||
def path_reference(
|
||||
filepath,
|
||||
base_src,
|
||||
base_dst,
|
||||
mode='AUTO',
|
||||
copy_subdir="",
|
||||
copy_set=None,
|
||||
library=None,
|
||||
):
|
||||
"""
|
||||
Return a filepath relative to a destination directory, for use with
|
||||
exporters.
|
||||
|
||||
:param filepath: the file path to return,
|
||||
supporting blenders relative '//' prefix.
|
||||
:type filepath: str
|
||||
:param base_src: the directory the *filepath* is relative to
|
||||
(normally the blend file).
|
||||
:type base_src: str
|
||||
:param base_dst: the directory the *filepath* will be referenced from
|
||||
(normally the export path).
|
||||
:type base_dst: str
|
||||
:param mode: the method used to reference the path.
|
||||
:type mode: Literal['AUTO', 'ABSOLUTE', 'RELATIVE', 'MATCH', 'STRIP', 'COPY']
|
||||
:param copy_subdir: the subdirectory of *base_dst* to use when mode='COPY'.
|
||||
:type copy_subdir: str
|
||||
:param copy_set: collect from/to pairs when mode='COPY',
|
||||
pass to *path_reference_copy* when exporting is done.
|
||||
:type copy_set: set[tuple[str, str]] | None
|
||||
:param library: The library this path is relative to.
|
||||
:type library: :class:`bpy.types.Library` | None
|
||||
:return: the new filepath.
|
||||
:rtype: str
|
||||
"""
|
||||
import os
|
||||
is_relative = filepath.startswith("//")
|
||||
filepath_abs = bpy.path.abspath(filepath, start=base_src, library=library)
|
||||
filepath_abs = os.path.normpath(filepath_abs)
|
||||
|
||||
if mode in {'ABSOLUTE', 'RELATIVE', 'STRIP'}:
|
||||
pass
|
||||
elif mode == 'MATCH':
|
||||
mode = 'RELATIVE' if is_relative else 'ABSOLUTE'
|
||||
elif mode == 'AUTO':
|
||||
mode = (
|
||||
'RELATIVE' if bpy.path.is_subdir(filepath_abs, base_dst) else
|
||||
'ABSOLUTE'
|
||||
)
|
||||
elif mode == 'COPY':
|
||||
subdir_abs = os.path.normpath(base_dst)
|
||||
if copy_subdir:
|
||||
subdir_abs = os.path.join(subdir_abs, copy_subdir)
|
||||
|
||||
filepath_cpy = os.path.join(subdir_abs, os.path.basename(filepath_abs))
|
||||
|
||||
copy_set.add((filepath_abs, filepath_cpy))
|
||||
|
||||
filepath_abs = filepath_cpy
|
||||
mode = 'RELATIVE'
|
||||
else:
|
||||
raise Exception("invalid mode given {!r}".format(mode))
|
||||
|
||||
if mode == 'ABSOLUTE':
|
||||
return filepath_abs
|
||||
elif mode == 'RELATIVE':
|
||||
# can't always find the relative path
|
||||
# (between drive letters on windows)
|
||||
try:
|
||||
return os.path.relpath(filepath_abs, base_dst)
|
||||
except ValueError:
|
||||
return filepath_abs
|
||||
elif mode == 'STRIP':
|
||||
return os.path.basename(filepath_abs)
|
||||
|
||||
|
||||
def path_reference_copy(copy_set, report=print):
|
||||
"""
|
||||
Execute copying files of path_reference
|
||||
|
||||
:param copy_set: set of (from, to) pairs to copy.
|
||||
:type copy_set: set[tuple[str, str]]
|
||||
:param report: function used for reporting warnings, takes a string argument.
|
||||
:type report: Callable[[str], None]
|
||||
"""
|
||||
if not copy_set:
|
||||
return
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
for file_src, file_dst in copy_set:
|
||||
if not os.path.exists(file_src):
|
||||
report("missing {!r}, not copying".format(file_src))
|
||||
elif os.path.exists(file_dst) and os.path.samefile(file_src, file_dst):
|
||||
pass
|
||||
else:
|
||||
dir_to = os.path.dirname(file_dst)
|
||||
|
||||
try:
|
||||
os.makedirs(dir_to, exist_ok=True)
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
try:
|
||||
shutil.copy(file_src, file_dst)
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def unique_name(key, name, name_dict, name_max=-1, clean_func=None, sep="."):
|
||||
"""
|
||||
Helper function for storing unique names which may have special characters
|
||||
stripped and restricted to a maximum length.
|
||||
|
||||
:param key: Unique item this name belongs to, name_dict[key] will be reused
|
||||
when available.
|
||||
This can be the object, mesh, material, etc instance itself.
|
||||
Any hashable object associated with the *name*.
|
||||
:type key: Any
|
||||
:param name: The name used to create a unique value in *name_dict*.
|
||||
:type name: str
|
||||
:param name_dict: This is used to cache namespace to ensure no collisions
|
||||
occur, this should be an empty dict initially and only modified by this
|
||||
function.
|
||||
:type name_dict: dict[Any, str]
|
||||
:param name_max: Maximum length of the name. When ``-1`` the name is unlimited.
|
||||
:type name_max: int
|
||||
:param clean_func: Function to call on *name* before creating a unique value.
|
||||
:type clean_func: Callable[[str], str] | None
|
||||
:param sep: Separator to use when between the name and a number when a
|
||||
duplicate name is found.
|
||||
:type sep: str
|
||||
:return: A unique name.
|
||||
:rtype: str
|
||||
"""
|
||||
name_new = name_dict.get(key)
|
||||
if name_new is None:
|
||||
count = 1
|
||||
name_dict_values = name_dict.values()
|
||||
name_new = name_new_orig = (
|
||||
name if clean_func is None
|
||||
else clean_func(name)
|
||||
)
|
||||
|
||||
if name_max == -1:
|
||||
while name_new in name_dict_values:
|
||||
name_new = "{:s}{:s}{:03d}".format(
|
||||
name_new_orig,
|
||||
sep,
|
||||
count,
|
||||
)
|
||||
count += 1
|
||||
else:
|
||||
name_new = name_new[:name_max]
|
||||
while name_new in name_dict_values:
|
||||
count_str = "{:03d}".format(count)
|
||||
name_new = "{:.{:d}s}{:s}{:s}".format(
|
||||
name_new_orig,
|
||||
name_max - (len(count_str) + 1),
|
||||
sep,
|
||||
count_str,
|
||||
)
|
||||
count += 1
|
||||
|
||||
name_dict[key] = name_new
|
||||
|
||||
return name_new
|
||||
155
blender-5.2.0/scripts/modules/bpy_extras/keyconfig_utils.py
Normal file
155
blender-5.2.0/scripts/modules/bpy_extras/keyconfig_utils.py
Normal file
@@ -0,0 +1,155 @@
|
||||
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"addon_keymap_register",
|
||||
"addon_keymap_unregister",
|
||||
"keyconfig_test",
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Add-on helpers to properly (un)register their own keymaps.
|
||||
|
||||
def addon_keymap_register(keymap_data):
|
||||
"""
|
||||
Register a set of keymaps for addons using a list of keymaps.
|
||||
|
||||
See 'blender_default.py' for examples of the format this takes.
|
||||
|
||||
:param keymap_data: A list of keymap definitions to register.
|
||||
:type keymap_data: list[tuple[str, dict[str, Any], dict[str, Any]]]
|
||||
"""
|
||||
import bpy
|
||||
wm = bpy.context.window_manager
|
||||
|
||||
from bl_keymap_utils.io import keymap_init_from_data
|
||||
|
||||
kconf = wm.keyconfigs.addon
|
||||
if not kconf:
|
||||
return # happens in background mode...
|
||||
for km_name, km_args, km_content in keymap_data:
|
||||
km_space_type = km_args["space_type"]
|
||||
km_region_type = km_args["region_type"]
|
||||
km_modal = km_args.get("modal", False)
|
||||
kmap = next(iter(
|
||||
k for k in kconf.keymaps
|
||||
if k.name == km_name and
|
||||
k.region_type == km_region_type and
|
||||
k.space_type == km_space_type and
|
||||
k.is_modal == km_modal
|
||||
), None)
|
||||
if kmap is None:
|
||||
kmap = kconf.keymaps.new(km_name, **km_args)
|
||||
keymap_init_from_data(kmap, km_content["items"], is_modal=km_modal)
|
||||
|
||||
|
||||
def addon_keymap_unregister(keymap_data):
|
||||
"""
|
||||
Unregister a set of keymaps for addons.
|
||||
|
||||
:param keymap_data: A list of keymap definitions to unregister.
|
||||
:type keymap_data: list[tuple[str, dict[str, Any], dict[str, Any]]]
|
||||
"""
|
||||
# NOTE: We must also clean up user keyconfig, else, if user has customized one of add-on's shortcut, this
|
||||
# customization remains in memory, and comes back when re-enabling the addon, causing a segfault... :/
|
||||
import bpy
|
||||
wm = bpy.context.window_manager
|
||||
|
||||
kconfs = wm.keyconfigs
|
||||
for kconf in (kconfs.user, kconfs.addon):
|
||||
for km_name, km_args, km_content in keymap_data:
|
||||
km_space_type = km_args["space_type"]
|
||||
km_region_type = km_args["region_type"]
|
||||
km_modal = km_args.get("modal", False)
|
||||
kmaps = (
|
||||
k for k in kconf.keymaps
|
||||
if k.name == km_name and
|
||||
k.region_type == km_region_type and
|
||||
k.space_type == km_space_type and
|
||||
k.is_modal == km_modal
|
||||
)
|
||||
for kmap in kmaps:
|
||||
for kmi_idname, _, _ in km_content["items"]:
|
||||
for kmi in kmap.keymap_items:
|
||||
if kmi.idname == kmi_idname:
|
||||
kmap.keymap_items.remove(kmi)
|
||||
# NOTE: We won't remove addons keymaps themselves, other addons might also use them!
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utility Functions
|
||||
|
||||
def keyconfig_test(kc):
|
||||
"""
|
||||
Test a key configuration for duplicate key-map item assignments.
|
||||
|
||||
:param kc: The key configuration to test.
|
||||
:type kc: :class:`bpy.types.KeyConfig`
|
||||
:return: True if any duplicates were found.
|
||||
:rtype: bool
|
||||
"""
|
||||
from bl_keymap_utils.io import kmi_args_as_data
|
||||
|
||||
def _kmistr(kmi, is_modal):
|
||||
if is_modal:
|
||||
kmi_id = kmi.propvalue
|
||||
else:
|
||||
kmi_id = kmi.idname
|
||||
return "{:s}({:s})".format(kmi_id, kmi_args_as_data(kmi))
|
||||
|
||||
def testEntry(kc, entry, src=None, parent=None):
|
||||
result = False
|
||||
|
||||
idname, spaceid, regionid, children = entry
|
||||
|
||||
km = kc.keymaps.find(idname, space_type=spaceid, region_type=regionid)
|
||||
|
||||
if km:
|
||||
km = km.active()
|
||||
is_modal = km.is_modal
|
||||
|
||||
if src:
|
||||
for item in km.keymap_items:
|
||||
if src.compare(item):
|
||||
print("===========")
|
||||
print(parent.name, "[parent]")
|
||||
print(_kmistr(src, is_modal).strip())
|
||||
print(km.name, "[child]")
|
||||
print(_kmistr(item, is_modal).strip())
|
||||
result = True
|
||||
|
||||
for child in children:
|
||||
if testEntry(kc, child, src, parent):
|
||||
result = True
|
||||
else:
|
||||
for i, src in enumerate(km.keymap_items):
|
||||
for child in children:
|
||||
if testEntry(kc, child, src, km):
|
||||
result = True
|
||||
|
||||
for j in range(len(km.keymap_items) - i - 1):
|
||||
item = km.keymap_items[j + i + 1]
|
||||
if src.compare(item):
|
||||
print("===========")
|
||||
print(km.name, "[self conflict]")
|
||||
print(_kmistr(src, is_modal).strip())
|
||||
print(_kmistr(item, is_modal).strip())
|
||||
result = True
|
||||
|
||||
for child in children:
|
||||
if testEntry(kc, child):
|
||||
result = True
|
||||
|
||||
return result
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Function body
|
||||
|
||||
from bl_keymap_utils import keymap_hierarchy
|
||||
result = False
|
||||
for entry in keymap_hierarchy.generate():
|
||||
if testEntry(kc, entry):
|
||||
result = True
|
||||
return result
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user