GPv3: Python API for frame, drawing and drawing attributes #124787
@ -40,7 +40,6 @@
|
||||
#
|
||||
######################################################
|
||||
|
||||
import struct
|
||||
import sys
|
||||
from string import Template # strings completion
|
||||
|
||||
|
@ -154,7 +154,6 @@ def unregister():
|
||||
from . import operators
|
||||
from . import properties
|
||||
from . import presets
|
||||
import atexit
|
||||
|
||||
bpy.app.handlers.version_update.remove(version_update.do_versions)
|
||||
|
||||
|
@ -360,6 +360,9 @@ typedef enum {
|
||||
GHOST_kStandardCursorBottomRightCorner,
|
||||
GHOST_kStandardCursorBottomLeftCorner,
|
||||
GHOST_kStandardCursorCopy,
|
||||
GHOST_kStandardCursorLeftHandle,
|
||||
GHOST_kStandardCursorRightHandle,
|
||||
GHOST_kStandardCursorBothHandles,
|
||||
GHOST_kStandardCursorCustom,
|
||||
|
||||
#define GHOST_kStandardCursorNumCursors (int(GHOST_kStandardCursorCustom) + 1)
|
||||
|
@ -2159,6 +2159,9 @@ static const GWL_Cursor_ShapeInfo ghost_wl_cursors = []() -> GWL_Cursor_ShapeInf
|
||||
CASE_CURSOR(GHOST_kStandardCursorBottomRightCorner, "bottom_right_corner");
|
||||
CASE_CURSOR(GHOST_kStandardCursorBottomLeftCorner, "bottom_left_corner");
|
||||
CASE_CURSOR(GHOST_kStandardCursorCopy, "copy");
|
||||
CASE_CURSOR(GHOST_kStandardCursorLeftHandle, "");
|
||||
CASE_CURSOR(GHOST_kStandardCursorRightHandle, "");
|
||||
CASE_CURSOR(GHOST_kStandardCursorBothHandles, "");
|
||||
CASE_CURSOR(GHOST_kStandardCursorCustom, "");
|
||||
}
|
||||
#undef CASE_CURSOR
|
||||
|
@ -823,6 +823,16 @@ HCURSOR GHOST_WindowWin32::getStandardCursor(GHOST_TStandardCursor shape) const
|
||||
case GHOST_kStandardCursorStop:
|
||||
cursor = ::LoadImage(module, "forbidden_cursor", IMAGE_CURSOR, cx, cy, flags);
|
||||
break; /* Slashed circle */
|
||||
case GHOST_kStandardCursorLeftHandle:
|
||||
cursor = ::LoadImage(module, "handle_left_cursor", IMAGE_CURSOR, cx, cy, flags);
|
||||
break;
|
||||
case GHOST_kStandardCursorRightHandle:
|
||||
cursor = ::LoadImage(module, "handle_right_cursor", IMAGE_CURSOR, cx, cy, flags);
|
||||
break;
|
||||
case GHOST_kStandardCursorBothHandles:
|
||||
cursor = ::LoadImage(module, "handle_both_cursor", IMAGE_CURSOR, cx, cy, flags);
|
||||
break;
|
||||
|
||||
case GHOST_kStandardCursorDefault:
|
||||
cursor = nullptr;
|
||||
break;
|
||||
|
BIN
release/windows/icons/cursors/handle_both.cur
Normal file
BIN
release/windows/icons/cursors/handle_both.cur
Normal file
Binary file not shown.
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
BIN
release/windows/icons/cursors/handle_left.cur
Normal file
BIN
release/windows/icons/cursors/handle_left.cur
Normal file
Binary file not shown.
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
BIN
release/windows/icons/cursors/handle_right.cur
Normal file
BIN
release/windows/icons/cursors/handle_right.cur
Normal file
Binary file not shown.
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
@ -36,6 +36,9 @@ splitv_cursor CURSOR "cursors/splitv.cur"
|
||||
zoomin_cursor CURSOR "cursors/zoomin.cur"
|
||||
zoomout_cursor CURSOR "cursors/zoomout.cur"
|
||||
forbidden_cursor CURSOR "cursors/forbidden.cur"
|
||||
handle_left_cursor CURSOR "cursors/handle_left.cur"
|
||||
handle_right_cursor CURSOR "cursors/handle_right.cur"
|
||||
handle_both_cursor CURSOR "cursors/handle_both.cur"
|
||||
|
||||
IDR_VERSION1 VERSIONINFO
|
||||
FILEVERSION BLEN_VER_RC_1, BLEN_VER_RC_2, BLEN_VER_RC_3, BLEN_VER_RC_4
|
||||
|
@ -183,6 +183,29 @@ def repo_stats_calc_outdated_for_repo_directory(repo_cache_store, repo_directory
|
||||
return package_count
|
||||
|
||||
|
||||
def repo_stats_calc_blocked(repo_cache_store):
|
||||
block_count = 0
|
||||
for (
|
||||
pkg_manifest_remote,
|
||||
pkg_manifest_local,
|
||||
) in zip(
|
||||
repo_cache_store.pkg_manifest_from_remote_ensure(error_fn=print),
|
||||
repo_cache_store.pkg_manifest_from_local_ensure(error_fn=print),
|
||||
):
|
||||
if (pkg_manifest_remote is None) or (pkg_manifest_local is None):
|
||||
continue
|
||||
|
||||
for pkg_id in pkg_manifest_local.keys():
|
||||
item_remote = pkg_manifest_remote.get(pkg_id)
|
||||
if item_remote is None:
|
||||
continue
|
||||
|
||||
if item_remote.block:
|
||||
block_count += 1
|
||||
|
||||
return block_count
|
||||
|
||||
|
||||
def repo_stats_calc():
|
||||
# NOTE: if repositories get very large, this could be optimized to only check repositories that have changed.
|
||||
# Although this isn't called all that often - it's unlikely to be a bottleneck.
|
||||
@ -213,7 +236,10 @@ def repo_stats_calc():
|
||||
|
||||
package_count += repo_stats_calc_outdated_for_repo_directory(repo_cache_store, repo_directory)
|
||||
|
||||
bpy.context.window_manager.extensions_updates = package_count
|
||||
wm = bpy.context.window_manager
|
||||
wm.extensions_updates = package_count
|
||||
|
||||
wm.extensions_blocked = repo_stats_calc_blocked(repo_cache_store)
|
||||
|
||||
|
||||
def print_debug(*args, **kw):
|
||||
|
@ -242,6 +242,9 @@ class subcmd_query:
|
||||
colorize(item.tagline or "<no tagline>", "faint"),
|
||||
))
|
||||
|
||||
if item_remote and item_remote.block:
|
||||
print(" Blocked:", colorize(item_remote.block.reason, "red"))
|
||||
|
||||
if item_warnings:
|
||||
# Including all text on one line doesn't work well here,
|
||||
# add warnings below the package.
|
||||
@ -266,6 +269,9 @@ class subcmd_query:
|
||||
extensions_warnings: Dict[str, List[str]] = addon_utils._extensions_warnings_get()
|
||||
assert isinstance(extensions_warnings, dict)
|
||||
|
||||
# Blocked and installed.
|
||||
blocked_and_installed_count = 0
|
||||
|
||||
for repo_index, (
|
||||
pkg_manifest_local,
|
||||
pkg_manifest_remote,
|
||||
@ -285,6 +291,21 @@ class subcmd_query:
|
||||
item_remote = pkg_manifest_remote.get(pkg_id) if (pkg_manifest_remote is not None) else None
|
||||
item_warnings = extensions_warnings.get("bl_ext.{:s}.{:s}".format(repo.module, pkg_id), [])
|
||||
list_item(pkg_id, item_local, item_remote, has_remote, item_warnings)
|
||||
if item_local and item_remote and item_remote.block:
|
||||
blocked_and_installed_count += 1
|
||||
sys.stdout.flush()
|
||||
|
||||
if blocked_and_installed_count:
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.write(
|
||||
" Warning: " +
|
||||
colorize("{:d} installed extension(s) are blocked!\n".format(blocked_and_installed_count), "red")
|
||||
)
|
||||
sys.stderr.write(
|
||||
" " +
|
||||
colorize("Uninstall them to remove this message!\n", "red")
|
||||
)
|
||||
sys.stderr.write("\n")
|
||||
|
||||
return True
|
||||
|
||||
|
@ -465,7 +465,7 @@ def _ui_refresh_apply():
|
||||
_region_refresh_registered()
|
||||
|
||||
|
||||
def _ui_refresh_timer():
|
||||
def _ui_refresh_timer_impl():
|
||||
wm = bpy.context.window_manager
|
||||
|
||||
# Ensure the first item is running, skipping any items that have no work.
|
||||
@ -517,6 +517,24 @@ def _ui_refresh_timer():
|
||||
return default_wait
|
||||
|
||||
|
||||
def _ui_refresh_timer():
|
||||
result = _ui_refresh_timer_impl()
|
||||
|
||||
# Ensure blocked packages are counted before finishing.
|
||||
if result is None:
|
||||
from . import (
|
||||
repo_cache_store_ensure,
|
||||
repo_stats_calc_blocked,
|
||||
)
|
||||
wm = bpy.context.window_manager
|
||||
repo_cache_store = repo_cache_store_ensure()
|
||||
extensions_blocked = repo_stats_calc_blocked(repo_cache_store)
|
||||
if extensions_blocked != wm.extensions_blocked:
|
||||
wm.extensions_blocked = extensions_blocked
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Internal Region Updating
|
||||
|
||||
|
@ -1847,6 +1847,9 @@ class EXTENSIONS_OT_package_upgrade_all(Operator, _ExtCmdMixIn):
|
||||
if item_local is None:
|
||||
# Not installed.
|
||||
continue
|
||||
if item_remote.block:
|
||||
# Blocked, don't touch.
|
||||
continue
|
||||
|
||||
if item_remote.version != item_local.version:
|
||||
packages_to_upgrade[repo_index].append(pkg_id)
|
||||
@ -3077,6 +3080,23 @@ class EXTENSIONS_OT_package_install(Operator, _ExtCmdMixIn):
|
||||
)
|
||||
return False
|
||||
|
||||
if item_remote.block:
|
||||
self._draw_override = (
|
||||
self._draw_override_errors,
|
||||
{
|
||||
"errors": [
|
||||
(
|
||||
"Repository \"{:s}\" has blocked \"{:s}\"\n"
|
||||
"for the following reason:"
|
||||
).format(repo_name, pkg_id),
|
||||
" " + item_remote.block.reason,
|
||||
"If you wish to install the extensions anyway,\n"
|
||||
"manually download and install the extension from disk."
|
||||
]
|
||||
}
|
||||
)
|
||||
return False
|
||||
|
||||
self._drop_variables = repo_index, repo_name, pkg_id, item_remote
|
||||
|
||||
self.repo_index = repo_index
|
||||
@ -3096,7 +3116,15 @@ class EXTENSIONS_OT_package_install(Operator, _ExtCmdMixIn):
|
||||
icon = 'ERROR'
|
||||
for error in errors:
|
||||
if isinstance(error, str):
|
||||
layout.label(text=error, translate=False, icon=icon)
|
||||
# Group text split by newlines more closely.
|
||||
# Without this, lines have too much vertical space.
|
||||
if "\n" in error:
|
||||
layout_aligned = layout.column(align=True)
|
||||
for error in error.split("\n"):
|
||||
layout_aligned.label(text=error, translate=False, icon=icon)
|
||||
icon = 'BLANK1'
|
||||
else:
|
||||
layout.label(text=error, translate=False, icon=icon)
|
||||
else:
|
||||
error(layout)
|
||||
icon = 'BLANK1'
|
||||
|
@ -432,6 +432,8 @@ def addons_panel_draw_items(
|
||||
addon_tags_exclude, # `Set[str]`
|
||||
enabled_only, # `bool`
|
||||
addon_extension_manifest_map, # `Dict[str, PkgManifest_Normalized]`
|
||||
addon_extension_block_map, # `Dict[str, PkgBlock_Normalized]`
|
||||
|
||||
show_development, # `bool`
|
||||
): # `-> Set[str]`
|
||||
# NOTE: this duplicates logic from `USERPREF_PT_addons` eventually this logic should be used instead.
|
||||
@ -464,6 +466,9 @@ def addons_panel_draw_items(
|
||||
if is_extension:
|
||||
item_warnings = []
|
||||
|
||||
if pkg_block := addon_extension_block_map.get(module_name):
|
||||
item_warnings.append("Blocked: {:s}".format(pkg_block.reason))
|
||||
|
||||
if value := extensions_warnings.get(module_name):
|
||||
item_warnings.extend(value)
|
||||
del value
|
||||
@ -677,10 +682,17 @@ def addons_panel_draw_impl(
|
||||
local_ex = ex
|
||||
|
||||
addon_extension_manifest_map = {}
|
||||
addon_extension_block_map = {}
|
||||
|
||||
for repo_index, pkg_manifest_local in enumerate(
|
||||
repo_cache_store.pkg_manifest_from_local_ensure(error_fn=error_fn_local)
|
||||
):
|
||||
# The `pkg_manifest_remote` is only needed for `PkgBlock_Normalized` data.
|
||||
for repo_index, (
|
||||
pkg_manifest_local,
|
||||
pkg_manifest_remote,
|
||||
) in enumerate(zip(
|
||||
repo_cache_store.pkg_manifest_from_local_ensure(error_fn=error_fn_local),
|
||||
repo_cache_store.pkg_manifest_from_remote_ensure(error_fn=error_fn_local),
|
||||
strict=True,
|
||||
)):
|
||||
if pkg_manifest_local is None:
|
||||
continue
|
||||
|
||||
@ -691,6 +703,11 @@ def addons_panel_draw_impl(
|
||||
module_name = repo_module_prefix + pkg_id
|
||||
addon_extension_manifest_map[module_name] = item_local
|
||||
|
||||
if pkg_manifest_remote is not None:
|
||||
if (item_remote := pkg_manifest_remote.get(pkg_id)) is not None:
|
||||
if (pkg_block := item_remote.block) is not None:
|
||||
addon_extension_block_map[module_name] = pkg_block
|
||||
|
||||
used_addon_module_name_map = {addon.module: addon for addon in prefs.addons}
|
||||
|
||||
module_names = addons_panel_draw_items(
|
||||
@ -702,6 +719,7 @@ def addons_panel_draw_impl(
|
||||
addon_tags_exclude=addon_tags_exclude,
|
||||
enabled_only=enabled_only,
|
||||
addon_extension_manifest_map=addon_extension_manifest_map,
|
||||
addon_extension_block_map=addon_extension_block_map,
|
||||
show_development=show_development,
|
||||
)
|
||||
|
||||
@ -1039,14 +1057,21 @@ class display_errors:
|
||||
box_header = layout.box()
|
||||
# Don't clip longer names.
|
||||
row = box_header.split(factor=0.9)
|
||||
row.label(text="Repository Access Errors:", icon='ERROR')
|
||||
row.label(text="Repository Alert:", icon='ERROR')
|
||||
rowsub = row.row(align=True)
|
||||
rowsub.alignment = 'RIGHT'
|
||||
rowsub.operator("extensions.status_clear_errors", text="", icon='X', emboss=False)
|
||||
|
||||
box_contents = box_header.box()
|
||||
for err in display_errors.errors_curr:
|
||||
box_contents.label(text=err)
|
||||
# Group text split by newlines more closely.
|
||||
# Without this, lines have too much vertical space.
|
||||
if "\n" in err:
|
||||
box_contents_align = box_contents.column(align=True)
|
||||
for err in err.split("\n"):
|
||||
box_contents_align.label(text=err)
|
||||
else:
|
||||
box_contents.label(text=err)
|
||||
|
||||
|
||||
class notify_info:
|
||||
@ -1115,21 +1140,33 @@ class ExtensionUI_Section:
|
||||
# Label & panel property or None not to define a header,
|
||||
# in this case the previous panel is used.
|
||||
"panel_header",
|
||||
"do_sort",
|
||||
"ui_ext_sort_fn",
|
||||
|
||||
"enabled",
|
||||
"extension_ui_list",
|
||||
)
|
||||
|
||||
def __init__(self, *, panel_header, do_sort):
|
||||
def __init__(self, *, panel_header, ui_ext_sort_fn):
|
||||
self.panel_header = panel_header
|
||||
self.do_sort = do_sort
|
||||
self.ui_ext_sort_fn = ui_ext_sort_fn
|
||||
|
||||
self.enabled = True
|
||||
self.extension_ui_list = []
|
||||
|
||||
def sort_by_name(self):
|
||||
self.extension_ui_list.sort(key=lambda ext_ui: (ext_ui.item_local or ext_ui.item_remote).name.casefold())
|
||||
@staticmethod
|
||||
def sort_by_blocked_and_name_fn(ext_ui):
|
||||
item_local = ext_ui.item_local
|
||||
item_remote = ext_ui.item_remote
|
||||
return (
|
||||
# Not blocked.
|
||||
not (item_remote is not None and item_remote.block is not None),
|
||||
# Name.
|
||||
(item_local or item_remote).name.casefold(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def sort_by_name_fn(ext_ui):
|
||||
return (ext_ui.item_local or ext_ui.item_remote).name.casefold()
|
||||
|
||||
|
||||
def extensions_panel_draw_online_extensions_request_impl(
|
||||
@ -1226,6 +1263,11 @@ def extension_draw_item(
|
||||
is_installed = item_local is not None
|
||||
has_remote = repo_item.remote_url != ""
|
||||
|
||||
if item_remote is not None:
|
||||
pkg_block = item_remote.block
|
||||
else:
|
||||
pkg_block = None
|
||||
|
||||
if is_enabled:
|
||||
item_warnings = extensions_warnings.get(pkg_repo_module_prefix(repo_item) + pkg_id, [])
|
||||
else:
|
||||
@ -1257,7 +1299,7 @@ def extension_draw_item(
|
||||
# Without checking `is_enabled` here, there is no way for the user to know if an extension
|
||||
# is enabled or not, which is useful to show - when they may be considering removing/updating
|
||||
# extensions based on them being used or not.
|
||||
if item_warnings:
|
||||
if pkg_block or item_warnings:
|
||||
sub.label(text=item.name, icon='ERROR', translate=False)
|
||||
else:
|
||||
sub.label(text=item.name, translate=False)
|
||||
@ -1274,8 +1316,9 @@ def extension_draw_item(
|
||||
row_right.alignment = 'RIGHT'
|
||||
|
||||
if has_remote and (item_remote is not None):
|
||||
if is_installed:
|
||||
# Include uninstall below.
|
||||
if pkg_block is not None:
|
||||
row_right.label(text="Blocked ")
|
||||
elif is_installed:
|
||||
if is_outdated:
|
||||
props = row_right.operator("extensions.package_install", text="Update")
|
||||
props.repo_index = repo_index
|
||||
@ -1328,6 +1371,10 @@ def extension_draw_item(
|
||||
col_b = split.column()
|
||||
col_a.alignment = "RIGHT"
|
||||
|
||||
if pkg_block is not None:
|
||||
col_a.label(text="Blocked")
|
||||
col_b.label(text=pkg_block.reason, translate=False)
|
||||
|
||||
if item_warnings:
|
||||
col_a.label(text="Warning")
|
||||
col_b.label(text=item_warnings[0])
|
||||
@ -1404,6 +1451,7 @@ def extensions_panel_draw_impl(
|
||||
|
||||
from . import repo_cache_store_ensure
|
||||
|
||||
wm = context.window_manager
|
||||
prefs = context.preferences
|
||||
|
||||
repo_cache_store = repo_cache_store_ensure()
|
||||
@ -1486,16 +1534,31 @@ def extensions_panel_draw_impl(
|
||||
|
||||
section_list = (
|
||||
# Installed (upgrade, enabled).
|
||||
ExtensionUI_Section(panel_header=(iface_("Installed"), "extension_show_panel_installed"), do_sort=True),
|
||||
ExtensionUI_Section(
|
||||
panel_header=(iface_("Installed"), "extension_show_panel_installed"),
|
||||
ui_ext_sort_fn=ExtensionUI_Section.sort_by_blocked_and_name_fn,
|
||||
),
|
||||
# Installed (upgrade, disabled). Use the previous panel.
|
||||
ExtensionUI_Section(panel_header=None, do_sort=True),
|
||||
ExtensionUI_Section(
|
||||
panel_header=None,
|
||||
ui_ext_sort_fn=ExtensionUI_Section.sort_by_name_fn,
|
||||
),
|
||||
# Installed (up-to-date, enabled). Use the previous panel.
|
||||
ExtensionUI_Section(panel_header=None, do_sort=True),
|
||||
ExtensionUI_Section(
|
||||
panel_header=None,
|
||||
ui_ext_sort_fn=ExtensionUI_Section.sort_by_name_fn,
|
||||
),
|
||||
# Installed (up-to-date, disabled).
|
||||
ExtensionUI_Section(panel_header=None, do_sort=True),
|
||||
ExtensionUI_Section(
|
||||
panel_header=None,
|
||||
ui_ext_sort_fn=ExtensionUI_Section.sort_by_name_fn,
|
||||
),
|
||||
# Available (remaining).
|
||||
# NOTE: don't use A-Z here to prevent name manipulation to bring an extension up on the ranks.
|
||||
ExtensionUI_Section(panel_header=(iface_("Available"), "extension_show_panel_available"), do_sort=False),
|
||||
ExtensionUI_Section(
|
||||
panel_header=(iface_("Available"), "extension_show_panel_available"),
|
||||
ui_ext_sort_fn=None,
|
||||
),
|
||||
)
|
||||
# The key is: (is_outdated, is_enabled) or None for the rest.
|
||||
section_table = {
|
||||
@ -1586,12 +1649,24 @@ def extensions_panel_draw_impl(
|
||||
pkg_manifest_local,
|
||||
pkg_manifest_remote,
|
||||
):
|
||||
section = (
|
||||
section_available if ext_ui.item_local is None else
|
||||
section_table[ext_ui.is_outdated, ext_ui.is_enabled]
|
||||
)
|
||||
if ext_ui.item_local is None:
|
||||
section = section_available
|
||||
else:
|
||||
if ((item_remote := ext_ui.item_remote) is not None) and (item_remote.block is not None):
|
||||
# Blocked are always first.
|
||||
section = section_installed
|
||||
else:
|
||||
section = section_table[ext_ui.is_outdated, ext_ui.is_enabled]
|
||||
|
||||
section.extension_ui_list.append(ext_ui)
|
||||
|
||||
if wm.extensions_blocked:
|
||||
errors_on_draw.append((
|
||||
"Found {:d} extension(s) blocked by the remote repository!\n"
|
||||
"Expand the extension to see the reason for blocking.\n"
|
||||
"Uninstall these extensions to remove the warning."
|
||||
).format(wm.extensions_blocked))
|
||||
|
||||
del repo_index, pkg_manifest_local, pkg_manifest_remote
|
||||
|
||||
section_installed.enabled = (params.has_installed_enabled or params.has_installed_disabled)
|
||||
@ -1605,7 +1680,7 @@ def extensions_panel_draw_impl(
|
||||
|
||||
if section.panel_header:
|
||||
label, prop_id = section.panel_header
|
||||
layout_header, layout_panel = layout.panel_prop(context.window_manager, prop_id)
|
||||
layout_header, layout_panel = layout.panel_prop(wm, prop_id)
|
||||
layout_header.label(text=label, translate=False)
|
||||
del label, prop_id, layout_header
|
||||
|
||||
@ -1614,8 +1689,8 @@ def extensions_panel_draw_impl(
|
||||
if not section.extension_ui_list:
|
||||
continue
|
||||
|
||||
if section.do_sort:
|
||||
section.sort_by_name()
|
||||
if section.ui_ext_sort_fn is not None:
|
||||
section.extension_ui_list.sort(key=section.ui_ext_sort_fn)
|
||||
|
||||
for ext_ui in section.extension_ui_list:
|
||||
extension_draw_item(
|
||||
|
@ -1121,6 +1121,29 @@ class CommandBatch:
|
||||
# Internal Repo Data Source
|
||||
#
|
||||
|
||||
class PkgBlock_Normalized(NamedTuple):
|
||||
reason: str
|
||||
|
||||
@staticmethod
|
||||
def from_dict_with_error_fn(
|
||||
block_dict: Dict[str, Any],
|
||||
*,
|
||||
# Only for useful error messages.
|
||||
pkg_idname: str,
|
||||
error_fn: Callable[[Exception], None],
|
||||
) -> Optional["PkgBlock_Normalized"]:
|
||||
|
||||
try:
|
||||
reason = block_dict["reason"]
|
||||
except KeyError as ex:
|
||||
error_fn(KeyError("{:s}: missing key {:s}".format(pkg_idname, str(ex))))
|
||||
return None
|
||||
|
||||
return PkgBlock_Normalized(
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
# See similar named tuple: `bl_pkg.cli.blender_ext.PkgManifest`.
|
||||
# This type is loaded from an external source and had it's valued parsed into a known "normalized" state.
|
||||
# Some transformation is performed to the purpose of displaying in the UI although this type isn't specifically for UI.
|
||||
@ -1147,12 +1170,16 @@ class PkgManifest_Normalized(NamedTuple):
|
||||
archive_size: int
|
||||
archive_url: str
|
||||
|
||||
# Taken from the `blocklist`.
|
||||
block: Optional[PkgBlock_Normalized]
|
||||
|
||||
@staticmethod
|
||||
def from_dict_with_error_fn(
|
||||
manifest_dict: Dict[str, Any],
|
||||
*,
|
||||
# Only for useful error messages.
|
||||
pkg_idname: str,
|
||||
pkg_block: Optional[PkgBlock_Normalized],
|
||||
error_fn: Callable[[Exception], None],
|
||||
) -> Optional["PkgManifest_Normalized"]:
|
||||
# NOTE: it is expected there are no errors here for typical usage.
|
||||
@ -1264,6 +1291,8 @@ class PkgManifest_Normalized(NamedTuple):
|
||||
|
||||
archive_size=field_archive_size,
|
||||
archive_url=field_archive_url,
|
||||
|
||||
block=pkg_block,
|
||||
)
|
||||
|
||||
|
||||
@ -1343,15 +1372,55 @@ def pkg_manifest_params_compatible_or_error(
|
||||
return None
|
||||
|
||||
|
||||
def repository_filter_packages(
|
||||
def repository_parse_blocklist(
|
||||
data: List[Dict[str, Any]],
|
||||
*,
|
||||
repo_directory: str,
|
||||
error_fn: Callable[[Exception], None],
|
||||
) -> Dict[str, PkgBlock_Normalized]:
|
||||
pkg_block_map = {}
|
||||
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
error_fn(Exception("found non dict item in repository \"blocklist\", found {:s}".format(str(type(item)))))
|
||||
continue
|
||||
|
||||
if (pkg_idname := repository_id_with_error_fn(
|
||||
item,
|
||||
repo_directory=repo_directory,
|
||||
error_fn=error_fn,
|
||||
)) is None:
|
||||
continue
|
||||
if (value := PkgBlock_Normalized.from_dict_with_error_fn(
|
||||
item,
|
||||
pkg_idname=pkg_idname,
|
||||
error_fn=error_fn,
|
||||
)) is None:
|
||||
# NOTE: typically we would skip invalid items
|
||||
# however as it's known this ID is blocked, create a dummy item.
|
||||
value = PkgBlock_Normalized(
|
||||
reason="Unknown (parse error)",
|
||||
)
|
||||
|
||||
pkg_block_map[pkg_idname] = value
|
||||
|
||||
return pkg_block_map
|
||||
|
||||
|
||||
def repository_parse_data_filtered(
|
||||
data: List[Dict[str, Any]],
|
||||
*,
|
||||
repo_directory: str,
|
||||
filter_params: PkgManifest_FilterParams,
|
||||
pkg_block_map: Dict[str, PkgBlock_Normalized],
|
||||
error_fn: Callable[[Exception], None],
|
||||
) -> Dict[str, PkgManifest_Normalized]:
|
||||
pkg_manifest_map = {}
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
error_fn(Exception("found non dict item in repository \"data\", found {:s}".format(str(type(item)))))
|
||||
continue
|
||||
|
||||
if (pkg_idname := repository_id_with_error_fn(
|
||||
item,
|
||||
repo_directory=repo_directory,
|
||||
@ -1369,6 +1438,7 @@ def repository_filter_packages(
|
||||
if (value := PkgManifest_Normalized.from_dict_with_error_fn(
|
||||
item,
|
||||
pkg_idname=pkg_idname,
|
||||
pkg_block=pkg_block_map.get(pkg_idname),
|
||||
error_fn=error_fn,
|
||||
)) is None:
|
||||
continue
|
||||
@ -1380,8 +1450,7 @@ def repository_filter_packages(
|
||||
|
||||
class RepoRemoteData(NamedTuple):
|
||||
version: str
|
||||
blocklist: List[str]
|
||||
# Converted from the `data` field.
|
||||
# Converted from the `data` & `blocklist` fields.
|
||||
pkg_manifest_map: Dict[str, PkgManifest_Normalized]
|
||||
|
||||
|
||||
@ -1528,15 +1597,29 @@ class _RepoDataSouce_JSON(_RepoDataSouce_ABC):
|
||||
|
||||
# It's important to assign this value even if it's "empty",
|
||||
# otherwise corrupt files will be detected as unset and continuously attempt to load.
|
||||
|
||||
repo_directory = os.path.dirname(self._filepath)
|
||||
|
||||
# Useful for testing:
|
||||
# `data_dict["blocklist"] = [{"id": "math_vis_console", "reason": "This is blocked"}]`
|
||||
|
||||
pkg_block_map = repository_parse_blocklist(
|
||||
data_dict.get("blocklist", []),
|
||||
repo_directory=repo_directory,
|
||||
error_fn=error_fn,
|
||||
)
|
||||
|
||||
pkg_manifest_map = repository_parse_data_filtered(
|
||||
data_dict.get("data", []),
|
||||
repo_directory=repo_directory,
|
||||
filter_params=self._filter_params,
|
||||
pkg_block_map=pkg_block_map,
|
||||
error_fn=error_fn,
|
||||
)
|
||||
|
||||
data = RepoRemoteData(
|
||||
version=data_dict.get("version", "v1"),
|
||||
blocklist=data_dict.get("blocklist", []),
|
||||
pkg_manifest_map=repository_filter_packages(
|
||||
data_dict.get("data", []),
|
||||
repo_directory=os.path.dirname(self._filepath),
|
||||
filter_params=self._filter_params,
|
||||
error_fn=error_fn,
|
||||
),
|
||||
pkg_manifest_map=pkg_manifest_map,
|
||||
)
|
||||
|
||||
self._data = data
|
||||
@ -1637,6 +1720,7 @@ class _RepoDataSouce_TOML_FILES(_RepoDataSouce_ABC):
|
||||
if (value := PkgManifest_Normalized.from_dict_with_error_fn(
|
||||
item_local,
|
||||
pkg_idname=pkg_idname,
|
||||
pkg_block=None,
|
||||
error_fn=error_fn,
|
||||
)) is None:
|
||||
continue
|
||||
@ -1648,7 +1732,6 @@ class _RepoDataSouce_TOML_FILES(_RepoDataSouce_ABC):
|
||||
# to use the same structure as the actual JSON.
|
||||
data = RepoRemoteData(
|
||||
version="v1",
|
||||
blocklist=[],
|
||||
pkg_manifest_map=pkg_manifest_map,
|
||||
)
|
||||
# End: compatibility change.
|
||||
@ -1854,6 +1937,7 @@ class _RepoCacheEntry:
|
||||
if (value := PkgManifest_Normalized.from_dict_with_error_fn(
|
||||
item_local,
|
||||
pkg_idname=pkg_idname,
|
||||
pkg_block=None,
|
||||
error_fn=error_fn,
|
||||
)) is not None:
|
||||
pkg_manifest_local[pkg_idname] = value
|
||||
|
@ -288,7 +288,7 @@ class CleanupPathsContext:
|
||||
|
||||
class PkgRepoData(NamedTuple):
|
||||
version: str
|
||||
blocklist: List[str]
|
||||
blocklist: List[Dict[str, Any]]
|
||||
data: List[Dict[str, Any]]
|
||||
|
||||
|
||||
@ -384,6 +384,12 @@ class PkgManifest_Archive(NamedTuple):
|
||||
archive_url: str
|
||||
|
||||
|
||||
class PkgServerRepoConfig(NamedTuple):
|
||||
"""Server configuration (for generating repositories)."""
|
||||
schema_version: str
|
||||
blocklist: List[Dict[str, Any]]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Generic Functions
|
||||
|
||||
@ -835,6 +841,33 @@ def pkg_manifest_from_archive_and_validate(
|
||||
return pkg_manifest_from_zipfile_and_validate(zip_fh, archive_subdir, strict=strict)
|
||||
|
||||
|
||||
def pkg_server_repo_config_from_toml_and_validate(
|
||||
filepath: str,
|
||||
) -> Union[PkgServerRepoConfig, str]:
|
||||
|
||||
if isinstance(result := toml_from_filepath_or_error(filepath), str):
|
||||
return result
|
||||
|
||||
if not (field_schema_version := result.get("schema_version", "")):
|
||||
return "missing \"schema_version\" field"
|
||||
|
||||
if not (field_blocklist := result.get("blocklist", "")):
|
||||
return "missing \"blocklist\" field"
|
||||
|
||||
for item in field_blocklist:
|
||||
if not isinstance(item, dict):
|
||||
return "blocklist contains non dictionary item, found ({:s})".format(str(type(item)))
|
||||
if not isinstance(value := item.get("id"), str):
|
||||
return "blocklist items must have have a string typed \"id\" entry, found {:s}".format(str(type(value)))
|
||||
if not isinstance(value := item.get("reason"), str):
|
||||
return "blocklist items must have have a string typed \"reason\" entry, found {:s}".format(str(type(value)))
|
||||
|
||||
return PkgServerRepoConfig(
|
||||
schema_version=field_schema_version,
|
||||
blocklist=field_blocklist,
|
||||
)
|
||||
|
||||
|
||||
def pkg_is_legacy_addon(filepath: str) -> bool:
|
||||
# Python file is legacy.
|
||||
if os.path.splitext(filepath)[1].lower() == ".py":
|
||||
@ -2258,9 +2291,9 @@ def repo_json_is_valid_or_error(filepath: str) -> Optional[str]:
|
||||
if not isinstance(value, list):
|
||||
return "Expected \"blocklist\" to be a list, not a {:s}".format(str(type(value)))
|
||||
for item in value:
|
||||
if isinstance(item, str):
|
||||
if isinstance(item, dict):
|
||||
continue
|
||||
return "Expected \"blocklist\" to be a list of strings, found {:s}".format(str(type(item)))
|
||||
return "Expected \"blocklist\" to be a list of dictionaries, found {:s}".format(str(type(item)))
|
||||
|
||||
if (value := result.get("data")) is None:
|
||||
return "Expected a \"data\" key which was not found"
|
||||
@ -2605,9 +2638,15 @@ def pkg_repo_data_from_json_or_error(json_data: Dict[str, Any]) -> Union[PkgRepo
|
||||
|
||||
if not isinstance((blocklist := json_data.get("blocklist", [])), list):
|
||||
return "expected \"blocklist\" to be a list"
|
||||
for item in blocklist:
|
||||
if not isinstance(item, dict):
|
||||
return "expected \"blocklist\" contain dictionary items"
|
||||
|
||||
if not isinstance((data := json_data.get("data", [])), list):
|
||||
return "expected \"data\" to be a list"
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
return "expected \"data\" contain dictionary items"
|
||||
|
||||
result_new = PkgRepoData(
|
||||
version=version,
|
||||
@ -2721,6 +2760,31 @@ def generic_arg_package_valid_tags(subparse: argparse.ArgumentParser) -> None:
|
||||
# -----------------------------------------------------------------------------
|
||||
# Argument Handlers ("server-generate" command)
|
||||
|
||||
def generic_arg_server_generate_repo_config(subparse: argparse.ArgumentParser) -> None:
|
||||
subparse.add_argument(
|
||||
"--repo-config",
|
||||
dest="repo_config",
|
||||
default="",
|
||||
metavar="REPO_CONFIG",
|
||||
help=(
|
||||
"An optional server configuration to include information which can't be detected.\n"
|
||||
"Defaults to ``blender_repo.toml`` (in the repository directory).\n"
|
||||
"\n"
|
||||
"This can be used to defined blocked extensions, for example ::\n"
|
||||
"\n"
|
||||
" schema_version = \"1.0.0\"\n"
|
||||
"\n"
|
||||
" [[blocklist]]\n"
|
||||
" id = \"my_example_package\"\n"
|
||||
" reason = \"Explanation for why this extension was blocked\"\n"
|
||||
" [[blocklist]]\n"
|
||||
" id = \"other_extenison\"\n"
|
||||
" reason = \"Another reason for why this is blocked\"\n"
|
||||
"\n"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def generic_arg_server_generate_html(subparse: argparse.ArgumentParser) -> None:
|
||||
subparse.add_argument(
|
||||
"--html",
|
||||
@ -3182,6 +3246,7 @@ class subcmd_server:
|
||||
msglog: MessageLogger,
|
||||
*,
|
||||
repo_dir: str,
|
||||
repo_config_filepath: str,
|
||||
html: bool,
|
||||
html_template: str,
|
||||
) -> bool:
|
||||
@ -3193,14 +3258,44 @@ class subcmd_server:
|
||||
msglog.fatal_error("Directory: {!r} not found!".format(repo_dir))
|
||||
return False
|
||||
|
||||
# Server manifest (optional), use if found.
|
||||
server_manifest_default = "blender_repo.toml"
|
||||
if not repo_config_filepath:
|
||||
server_manifest_test = os.path.join(repo_dir, server_manifest_default)
|
||||
if os.path.exists(server_manifest_test):
|
||||
repo_config_filepath = server_manifest_test
|
||||
del server_manifest_test
|
||||
del server_manifest_default
|
||||
|
||||
repo_config = None
|
||||
if repo_config_filepath:
|
||||
repo_config = pkg_server_repo_config_from_toml_and_validate(repo_config_filepath)
|
||||
if isinstance(repo_config, str):
|
||||
msglog.fatal_error("parsing repository configuration {!r}, {:s}".format(
|
||||
repo_config,
|
||||
repo_config_filepath,
|
||||
))
|
||||
return False
|
||||
if repo_config.schema_version != "1.0.0":
|
||||
msglog.fatal_error("unsupported schema version {!r} in {:s}, expected 1.0.0".format(
|
||||
repo_config.schema_version,
|
||||
repo_config_filepath,
|
||||
))
|
||||
return False
|
||||
assert repo_config is None or isinstance(repo_config, PkgServerRepoConfig)
|
||||
|
||||
repo_data_idname_map: Dict[str, List[PkgManifest]] = {}
|
||||
repo_data: List[Dict[str, Any]] = []
|
||||
|
||||
# Write package meta-data into each directory.
|
||||
repo_gen_dict = {
|
||||
"version": "v1",
|
||||
"blocklist": [],
|
||||
"blocklist": [] if repo_config is None else repo_config.blocklist,
|
||||
"data": repo_data,
|
||||
}
|
||||
|
||||
del repo_config
|
||||
|
||||
for entry in os.scandir(repo_dir):
|
||||
if not entry.name.endswith(PKG_EXT):
|
||||
continue
|
||||
@ -3599,6 +3694,14 @@ class subcmd_client:
|
||||
for pkg_info in json_data_pkg_info:
|
||||
json_data_pkg_info_map[pkg_info["id"]].append(pkg_info)
|
||||
|
||||
# NOTE: we could have full validation as a separate function,
|
||||
# currently install is the only place this is needed.
|
||||
json_data_pkg_block_map = {
|
||||
pkg_idname: pkg_block.get("reason", "Unknown")
|
||||
for pkg_block in pkg_repo_data.blocklist
|
||||
if (pkg_idname := pkg_block.get("id"))
|
||||
}
|
||||
|
||||
platform_this = platform_from_this_system()
|
||||
|
||||
has_fatal_error = False
|
||||
@ -3609,6 +3712,11 @@ class subcmd_client:
|
||||
has_fatal_error = True
|
||||
continue
|
||||
|
||||
if (result := json_data_pkg_block_map.get(pkg_idname)) is not None:
|
||||
msglog.fatal_error("Package \"{:s}\", is blocked: {:s}".format(pkg_idname, result))
|
||||
has_fatal_error = True
|
||||
continue
|
||||
|
||||
pkg_info_list = [
|
||||
pkg_info for pkg_info in pkg_info_list
|
||||
if not repository_filter_skip(
|
||||
@ -4407,6 +4515,7 @@ def unregister():
|
||||
if not subcmd_server.generate(
|
||||
MessageLogger(msg_fn_no_done),
|
||||
repo_dir=repo_dir,
|
||||
repo_config_filepath="",
|
||||
html=True,
|
||||
html_template="",
|
||||
):
|
||||
@ -4463,6 +4572,7 @@ def argparse_create_server_generate(
|
||||
)
|
||||
|
||||
generic_arg_repo_dir(subparse)
|
||||
generic_arg_server_generate_repo_config(subparse)
|
||||
generic_arg_server_generate_html(subparse)
|
||||
generic_arg_server_generate_html_template(subparse)
|
||||
if args_internal:
|
||||
@ -4472,6 +4582,7 @@ def argparse_create_server_generate(
|
||||
func=lambda args: subcmd_server.generate(
|
||||
msglog_from_args(args),
|
||||
repo_dir=args.repo_dir,
|
||||
repo_config_filepath=args.repo_config,
|
||||
html=args.html,
|
||||
html_template=args.html_template,
|
||||
),
|
||||
|
@ -861,6 +861,90 @@ class TestModuleViolation(TestWithTempBlenderUser_MixIn, unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestBlockList(TestWithTempBlenderUser_MixIn, unittest.TestCase):
|
||||
|
||||
def test_blocked(self) -> None:
|
||||
"""
|
||||
Warn when:
|
||||
- extensions add themselves to the ``sys.path``.
|
||||
- extensions add top-level modules into ``sys.modules``.
|
||||
"""
|
||||
repo_id = "test_repo_blocklist"
|
||||
repo_name = "MyTestRepoBlocked"
|
||||
|
||||
self.repo_add(repo_id=repo_id, repo_name=repo_name)
|
||||
|
||||
pkg_idnames = (
|
||||
"my_test_pkg_a",
|
||||
"my_test_pkg_b",
|
||||
"my_test_pkg_c",
|
||||
)
|
||||
|
||||
# Create a package contents.
|
||||
for pkg_idname in pkg_idnames:
|
||||
self.build_package(pkg_idname=pkg_idname)
|
||||
|
||||
repo_config_filepath = os.path.join(TEMP_DIR_REMOTE, "blender_repo.toml")
|
||||
with open(repo_config_filepath, "w", encoding="utf8") as fh:
|
||||
fh.write(
|
||||
'''schema_version = "1.0.0"\n'''
|
||||
'''[[blocklist]]\n'''
|
||||
'''id = "my_test_pkg_a"\n'''
|
||||
'''reason = "One example reason"\n'''
|
||||
'''[[blocklist]]\n'''
|
||||
'''id = "my_test_pkg_c"\n'''
|
||||
'''reason = "Another example reason"\n'''
|
||||
)
|
||||
|
||||
# Generate the repository.
|
||||
stdout = run_blender_extensions_no_errors((
|
||||
"server-generate",
|
||||
"--repo-dir", TEMP_DIR_REMOTE,
|
||||
"--repo-config", repo_config_filepath,
|
||||
))
|
||||
self.assertEqual(stdout, "found 3 packages.\n")
|
||||
|
||||
stdout = run_blender_extensions_no_errors((
|
||||
"sync",
|
||||
))
|
||||
self.assertEqual(
|
||||
stdout.rstrip("\n").split("\n")[-1],
|
||||
"STATUS Extensions list for \"{:s}\" updated".format(repo_name),
|
||||
)
|
||||
|
||||
# List packages.
|
||||
stdout = run_blender_extensions_no_errors(("list",))
|
||||
self.assertEqual(
|
||||
stdout,
|
||||
(
|
||||
'''Repository: "{:s}" (id={:s})\n'''
|
||||
''' my_test_pkg_a: "My Test Pkg A", This is a tagline\n'''
|
||||
''' Blocked: One example reason\n'''
|
||||
''' my_test_pkg_b: "My Test Pkg B", This is a tagline\n'''
|
||||
''' my_test_pkg_c: "My Test Pkg C", This is a tagline\n'''
|
||||
''' Blocked: Another example reason\n'''
|
||||
).format(
|
||||
repo_name,
|
||||
repo_id,
|
||||
))
|
||||
|
||||
# Install the package into Blender.
|
||||
stdout = run_blender_extensions_no_errors(("install", pkg_idnames[1], "--enable"))
|
||||
self.assertEqual(
|
||||
[line for line in stdout.split("\n") if line.startswith("STATUS ")][0],
|
||||
"STATUS Installed \"{:s}\"".format(pkg_idnames[1])
|
||||
)
|
||||
|
||||
# Ensure blocking works, fail to install the package into Blender.
|
||||
stdout = run_blender_extensions_no_errors(("install", pkg_idnames[0], "--enable"))
|
||||
self.assertEqual(
|
||||
[line for line in stdout.split("\n") if line.startswith("FATAL_ERROR ")][0],
|
||||
"FATAL_ERROR Package \"{:s}\", is blocked: One example reason".format(pkg_idnames[0])
|
||||
)
|
||||
|
||||
# Install the package into Blender.
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# pylint: disable-next=global-statement
|
||||
global TEMP_DIR_BLENDER_USER, TEMP_DIR_REMOTE, TEMP_DIR_LOCAL, TEMP_DIR_TMPDIR, TEMP_DIR_REMOTE_AS_URL
|
||||
|
@ -28,7 +28,7 @@ import contextlib
|
||||
from typing import Iterable, Optional, Union, Any, TypeAlias, Iterator
|
||||
|
||||
import bpy
|
||||
from bpy.types import Context, Object, Operator, Panel, PoseBone, UILayout, FCurve, Camera, FModifierStepped
|
||||
from bpy.types import Context, Object, Operator, Panel, PoseBone, UILayout, Camera
|
||||
from mathutils import Matrix
|
||||
|
||||
|
||||
|
@ -899,7 +899,7 @@ def blen_read_animations_action_item(action, item, cnodes, fps, anim_offset, glo
|
||||
'Bake' loc/rot/scale into the action,
|
||||
taking any pre_ and post_ matrix into account to transform from fbx into blender space.
|
||||
"""
|
||||
from bpy.types import Object, PoseBone, ShapeKey, Material, Camera
|
||||
from bpy.types import ShapeKey, Material, Camera
|
||||
|
||||
fbx_curves: dict[bytes, dict[int, FBXElem]] = {}
|
||||
for curves, fbxprop in cnodes.values():
|
||||
|
@ -1244,6 +1244,11 @@ class USERPREF_PT_theme_text_style(ThemePanel, CenterAlignMixIn, Panel):
|
||||
layout.label(text="Widget")
|
||||
self._ui_font_style(layout, style.widget)
|
||||
|
||||
layout.separator()
|
||||
|
||||
layout.label(text="Tooltip")
|
||||
self._ui_font_style(layout, style.tooltip)
|
||||
|
||||
|
||||
class USERPREF_PT_theme_bone_color_sets(ThemePanel, CenterAlignMixIn, Panel):
|
||||
bl_label = "Bone Color Sets"
|
||||
|
@ -3112,7 +3112,7 @@ class VIEW3D_MT_object_context_menu(Menu):
|
||||
|
||||
if obj.empty_display_type == 'IMAGE':
|
||||
layout.operator("image.convert_to_mesh_plane", text="Convert to Mesh Plane")
|
||||
layout.operator("gpencil.trace_image")
|
||||
layout.operator("grease_pencil.trace_image")
|
||||
|
||||
layout.separator()
|
||||
|
||||
@ -3512,7 +3512,7 @@ class VIEW3D_MT_object_convert(Menu):
|
||||
# Potrace lib dependency.
|
||||
if bpy.app.build_options.potrace:
|
||||
layout.operator("image.convert_to_mesh_plane", text="Convert to Mesh Plane", icon='MESH_PLANE')
|
||||
layout.operator("gpencil.trace_image", icon='OUTLINER_OB_GREASEPENCIL')
|
||||
layout.operator("grease_pencil.trace_image", icon='OUTLINER_OB_GREASEPENCIL')
|
||||
|
||||
if ob and ob.type == 'CURVES':
|
||||
layout.operator("curves.convert_to_particle_system", text="Particle System")
|
||||
|
@ -29,7 +29,7 @@ extern "C" {
|
||||
|
||||
/* Blender file format version. */
|
||||
#define BLENDER_FILE_VERSION BLENDER_VERSION
|
||||
#define BLENDER_FILE_SUBVERSION 11
|
||||
#define BLENDER_FILE_SUBVERSION 12
|
||||
|
||||
/* Minimum Blender version that supports reading file written with the current
|
||||
* version. Older Blender versions will test this and cancel loading the file, showing a warning to
|
||||
|
@ -14,7 +14,6 @@
|
||||
#include "BLI_span.hh"
|
||||
#include "BLI_utildefines.h"
|
||||
|
||||
struct BMesh;
|
||||
struct Mesh;
|
||||
|
||||
/* Mesh Fairing. */
|
||||