From 594d72d7e15e34944d9d3b9b6d3504a264468e10 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 10 Mar 2026 19:05:17 +0500 Subject: [PATCH] Quick Favorites Manager Blender doesn't have it's own quick favorites manager and working with them can be not very flexible - you can add them in context menu and remove them from Quick Favorites menu. But you can't reorder them, you can't rename them and you can't even add a new button to favorites if it's not added by some addon in the UI. Have been stumbling upon this for awhile and decided to create an experimental manager UI for this. Things it can do: - help user create a button with any operator in Blender and properties they prefer to then save it Quick Favorites. Which seems can be very useful in Bonsai, since you can create separate buttons for all kinds of selectors expressions, class assignment or other operators. - it can import quick favorites from user's actual current quick favorites, so they can just modify them a bit, reorder, rename and then add them again. - Since quick favorites are not exposed to Python API in Blender, we're using a very hacky way to retrieve them from Blender and don't provide our own buttons for adding and removing quick favorites, as it may be dangerous and even more hacky in implementation. So the workflow for user is to either generate some buttons and add them to quick favorites using Manager or to import it's own quick favorites, then change them how they like, then remove quick favorites using usual quick favorites menu and then add new button one by one. Small demo - https://files.catbox.moe/vyffp6.mp4 --- src/bonsai/bonsai/bim/module/misc/__init__.py | 8 + src/bonsai/bonsai/bim/module/misc/operator.py | 148 ++++++++++++++++- src/bonsai/bonsai/bim/module/misc/prop.py | 63 +++++++- src/bonsai/bonsai/bim/module/misc/ui.py | 67 ++++++++ src/bonsai/bonsai/core/tool.py | 1 + src/bonsai/bonsai/tool/blender.py | 9 ++ src/bonsai/bonsai/tool/misc.py | 152 +++++++++++++++++- src/bonsai/test/tool/test_misc.py | 6 + 8 files changed, 449 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/misc/__init__.py b/src/bonsai/bonsai/bim/module/misc/__init__.py index a71e467b7f..167cb65d6f 100644 --- a/src/bonsai/bonsai/bim/module/misc/__init__.py +++ b/src/bonsai/bonsai/bim/module/misc/__init__.py @@ -21,6 +21,11 @@ import bpy from . import operator, prop, ui classes = ( + operator.ImportQuickFavorites, + operator.RemoveQuickFavoritesItem, + operator.MoveQuickFavoritesItem, + operator.AddQuickFavoritesItem, + operator.EnableQuickFavoriteSearch, operator.DrawSystemArrows, operator.GetConnectedSystemElements, operator.IfcSverchokUseBonsaiFile, @@ -28,8 +33,11 @@ classes = ( operator.SetOverrideColour, operator.SnapSpacesTogether, operator.SplitAlongEdge, + prop.QuickFavoriteProperty, + prop.QuickFavoritesItem, prop.BIMMiscProperties, ui.BIM_PT_misc_utilities, + ui.BIM_PT_quick_favorites_manager, ) diff --git a/src/bonsai/bonsai/bim/module/misc/operator.py b/src/bonsai/bonsai/bim/module/misc/operator.py index 1d756f7b1e..994a3a3829 100644 --- a/src/bonsai/bonsai/bim/module/misc/operator.py +++ b/src/bonsai/bonsai/bim/module/misc/operator.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -from typing import TYPE_CHECKING, Literal, assert_never, get_args +from typing import TYPE_CHECKING, Literal, assert_never, cast, get_args import bpy import ifcopenshell.util.geolocation @@ -30,6 +30,9 @@ import bonsai.core.misc as core import bonsai.core.root import bonsai.tool as tool +if TYPE_CHECKING: + from bpy.stub_internal import rna_enums + class SetOverrideColour(bpy.types.Operator): bl_idname = "bim.set_override_colour" @@ -352,6 +355,149 @@ class DrawSystemArrows(bpy.types.Operator, tool.Ifc.Operator): return matrix +class EnableQuickFavoriteSearch(bpy.types.Operator): + bl_idname = "bim.enable_quick_favorite_search" + bl_label = "Enable Search" + bl_options = {"REGISTER", "UNDO"} + index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + index: int + + def execute(self, context) -> set["rna_enums.OperatorReturnItems"]: + props = tool.Misc.get_misc_props() + fav = props.quick_favorites[self.index] + name = fav.search.strip() + + # TODO: don't use try / except. + try: + module, func = name.split(".", 1) + op = getattr(getattr(bpy.ops, module), func) + rna = cast(bpy.types.Struct, op.get_rna_type()) + except (ValueError, AttributeError): + self.report({"ERROR"}, f"Operator '{name}' not found.") + return {"CANCELLED"} + + fav.operator_id = name + fav.label = rna.name + fav.properties.clear() + has_skipped = False + for p in rna.properties: + # skip silently, e.g. `rna_type` is a PointerProperty + if isinstance(p, bpy.types.PointerProperty): + continue + if isinstance(p, (bpy.types.FloatProperty, bpy.types.BoolProperty, bpy.types.IntProperty)) and p.is_array: + print(f"Array property '{p.identifier}' is not supported, skipping.") + has_skipped = True + continue + item = fav.properties.add() + item.name = p.identifier + item.display_name = p.name + if isinstance(p, bpy.types.FloatProperty): + item.value_prop = "float_value" + item.float_value = p.default + elif isinstance(p, bpy.types.BoolProperty): + item.value_prop = "bool_value" + item.bool_value = p.default + elif isinstance(p, bpy.types.IntProperty): + item.value_prop = "int_value" + item.int_value = p.default + elif isinstance(p, (bpy.types.StringProperty, bpy.types.EnumProperty)): + # TODO: support displaying enum items in the UI for EnumProperty + item.value_prop = "string_value" + item.string_value = p.default + else: + print(f"Unhandled property type {type(p).__name__} for '{p.identifier}', skipping.") + has_skipped = True + if has_skipped: + self.report({"WARNING"}, "Some properties were skipped, see the system console for details.") + return {"FINISHED"} + + +class ImportQuickFavorites(bpy.types.Operator): + bl_idname = "bim.import_quick_favorites" + bl_label = "Import Quick Favorites" + bl_description = "Import operators from Blender's Quick Favorites menu, including their configured properties" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context) -> set["rna_enums.OperatorReturnItems"]: + props = tool.Misc.get_misc_props() + props.quick_favorites.clear() + + has_missing_props = False + for i, qf in enumerate(tool.Misc.QuickFavorites.get_quick_favorites()): + fav = props.quick_favorites.add() + fav.label = qf.ui_name + fav.search = qf.op_idname_py + bpy.ops.bim.enable_quick_favorite_search(index=i) + fav.label = qf.ui_name or fav.label + + for prop in fav.properties: + prop.is_active = prop.name in qf.props + + for key, value in qf.props.items(): + if key not in fav.properties: + print(f"Property '{key}' not found in operator '{qf.op_idname_py}'.") + has_missing_props = True + continue + item = fav.properties[key] + setattr(item, item.value_prop, value) + + if has_missing_props: + self.report( + {"WARNING"}, "Some properties were not found during import, see the system console for details." + ) + return {"FINISHED"} + + +class MoveQuickFavoritesItem(bpy.types.Operator): + bl_idname = "bim.move_quick_favorites_item" + bl_label = "Move Quick Favorites Item" + bl_options = {"REGISTER", "UNDO"} + index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + direction: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + items=[("UP", "Up", ""), ("DOWN", "Down", "")] + ) + + if TYPE_CHECKING: + index: int + direction: Literal["UP", "DOWN"] + + def execute(self, context) -> set["rna_enums.OperatorReturnItems"]: + props = tool.Misc.get_misc_props() + total = len(props.quick_favorites) + new_index = self.index - 1 if self.direction == "UP" else self.index + 1 + if 0 <= new_index < total: + props.quick_favorites.move(self.index, new_index) + return {"FINISHED"} + + +class RemoveQuickFavoritesItem(bpy.types.Operator): + bl_idname = "bim.remove_quick_favorites_item" + bl_label = "Remove Quick Favorites Item" + bl_options = {"REGISTER", "UNDO"} + index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + index: int + + def execute(self, context) -> set["rna_enums.OperatorReturnItems"]: + props = tool.Misc.get_misc_props() + props.quick_favorites.remove(self.index) + return {"FINISHED"} + + +class AddQuickFavoritesItem(bpy.types.Operator): + bl_idname = "bim.add_quick_favorites_item" + bl_label = "Add Quick Favorites Item" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context) -> set["rna_enums.OperatorReturnItems"]: + props = tool.Misc.get_misc_props() + props.quick_favorites.add() + return {"FINISHED"} + + class IfcSverchokUseBonsaiFile(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.ifcsverchok_use_bonsai_file" bl_label = "Use Bonsai IFC File" diff --git a/src/bonsai/bonsai/bim/module/misc/prop.py b/src/bonsai/bonsai/bim/module/misc/prop.py index 093ff60046..4ff1eb53f3 100644 --- a/src/bonsai/bonsai/bim/module/misc/prop.py +++ b/src/bonsai/bonsai/bim/module/misc/prop.py @@ -16,25 +16,82 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal, get_args +import bpy from bpy.props import ( + BoolProperty, + CollectionProperty, + EnumProperty, + FloatProperty, FloatVectorProperty, IntProperty, + StringProperty, ) from bpy.types import PropertyGroup +QuickFavoriteValueType = Literal["float_value", "bool_value", "int_value", "string_value"] + + +class QuickFavoriteProperty(PropertyGroup): + name: StringProperty(name="Name", default="") # pyright: ignore[reportRedeclaration] + display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration] + value_prop: EnumProperty( # pyright: ignore[reportRedeclaration] + name="Value Prop", + items=tuple((v, v, "") for v in get_args(QuickFavoriteValueType)), + ) + string_value: StringProperty(name="String Value", default="") # pyright: ignore[reportRedeclaration] + float_value: FloatProperty(name="Float Value", default=0.0) # pyright: ignore[reportRedeclaration] + int_value: IntProperty(name="Int Value", default=0) # pyright: ignore[reportRedeclaration] + bool_value: BoolProperty(name="Bool Value", default=False) # pyright: ignore[reportRedeclaration] + is_active: BoolProperty( # pyright: ignore[reportRedeclaration] + name="Is Active", + description="Only active properties will be added to the operator when invoked from Quick Favorites", + default=False, + ) + + if TYPE_CHECKING: + name: str + display_name: str + value_prop: QuickFavoriteValueType + string_value: str + float_value: float + int_value: int + bool_value: bool + is_active: bool + + +class QuickFavoritesItem(PropertyGroup): + is_expanded: BoolProperty(name="Is Expanded", default=True) # pyright: ignore[reportRedeclaration] + search: StringProperty(name="Search", default="") # pyright: ignore[reportRedeclaration] + properties: CollectionProperty(type=QuickFavoriteProperty) # pyright: ignore[reportRedeclaration] + operator_id: StringProperty(name="Operator ID", default="") # pyright: ignore[reportRedeclaration] + label: StringProperty( # pyright: ignore[reportRedeclaration] + name="Label", + description="Label that will be used in Quick Favorites for this operator", + default="", + ) + + if TYPE_CHECKING: + is_expanded: bool + search: str + properties: bpy.types.bpy_prop_collection_idprop[QuickFavoriteProperty] + operator_id: str + label: str + class BIMMiscProperties(PropertyGroup): - total_storeys: IntProperty( + total_storeys: IntProperty( # pyright: ignore[reportRedeclaration] name="Total Storeys", description="Number of storeys above object's storey to take into account for resizing", default=1, ) - override_colour: FloatVectorProperty( + override_colour: FloatVectorProperty( # pyright: ignore[reportRedeclaration] name="Override Colour", subtype="COLOR", default=(1, 0, 0, 1), min=0.0, max=1.0, size=4 ) + quick_favorites: CollectionProperty(type=QuickFavoritesItem) # pyright: ignore[reportRedeclaration] if TYPE_CHECKING: total_storeys: int override_colour: tuple[float, float, float, float] + quick_favorites: bpy.types.bpy_prop_collection_idprop[QuickFavoritesItem] diff --git a/src/bonsai/bonsai/bim/module/misc/ui.py b/src/bonsai/bonsai/bim/module/misc/ui.py index af0be1dfbc..ca79281849 100644 --- a/src/bonsai/bonsai/bim/module/misc/ui.py +++ b/src/bonsai/bonsai/bim/module/misc/ui.py @@ -32,6 +32,7 @@ class BIM_PT_misc_utilities(bpy.types.Panel): def draw(self, context): layout = self.layout + assert layout props = tool.Misc.get_misc_props() row = layout.split(factor=0.2, align=True) row.prop(props, "override_colour", text="") @@ -58,3 +59,69 @@ class BIM_PT_misc_utilities(bpy.types.Panel): row.operator("bim.disable_editing_sketch_extrusion_profile", text="", icon="CANCEL") row = layout.row() row.operator("bim.import_plot", text="Import Plot Coordinates", icon="FILE_FOLDER") + + +class BIM_PT_quick_favorites_manager(bpy.types.Panel): + bl_idname = "BIM_PT_quick_favorites_manager" + bl_label = "Quick Favorites Manager" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "output" + bl_options = {"DEFAULT_CLOSED"} + bl_parent_id = "BIM_PT_tab_sandbox" + + def draw(self, context): + layout = self.layout + assert layout + props = tool.Misc.get_misc_props() + + row = layout.row(align=True) + row.label(text="Quick Favorites:") + row.operator("bim.import_quick_favorites", text="", icon="BLENDER") + row.operator("bim.add_quick_favorites_item", text="", icon="ADD") + op = row.operator("bim.show_description", text="", icon="INFO") + op.attr_name = "Quick Favorites Manager" + op.description = ( + "Blender does not support editing Quick Favorites natively. " + "This manager allows you to load existing Quick Favorites operators, " + "configure their properties and labels, and re-add them to the menu with customized settings." + ) + + for fav in props.quick_favorites: + if fav.operator_id: + row = layout.row() + op = row.operator(fav.operator_id, text=fav.label) + for item in fav.properties: + if item.is_active: + setattr(op, item.name, getattr(item, item.value_prop)) + + layout.separator() + + for i, fav in enumerate(props.quick_favorites): + box = layout.box() + row = box.row(align=True) + row.prop(fav, "is_expanded", text="", icon="TRIA_DOWN" if fav.is_expanded else "TRIA_RIGHT", emboss=False) + row.prop(fav, "label", text="") + if i > 0: + up = row.operator("bim.move_quick_favorites_item", text="", icon="TRIA_UP") + up.index = i + up.direction = "UP" + if i < len(props.quick_favorites) - 1: + down = row.operator("bim.move_quick_favorites_item", text="", icon="TRIA_DOWN") + down.index = i + down.direction = "DOWN" + row.operator("bim.remove_quick_favorites_item", text="", icon="X").index = i + if not fav.is_expanded: + continue + row = box.row(align=True) + row.prop(fav, "search", text="") + row.operator("bim.enable_quick_favorite_search", text="", icon="VIEWZOOM").index = i + if not fav.operator_id: + continue + layout.separator() + box.label(text="Properties:") + prop_box = box.box() + for item in fav.properties: + row = prop_box.row(align=True) + row.prop(item, item.value_prop, text=item.display_name) + row.prop(item, "is_active", text="", icon="RADIOBUT_ON" if item.is_active else "RADIOBUT_OFF") diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index a0b6290b12..234c79c53c 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -99,6 +99,7 @@ class Blender: def get_object_bounding_box(cls, obj): pass def get_selected_objects(cls, include_active=False): pass def get_viewport_context(cls): pass + def operator_idname_to_py(cls, idname): pass def is_ifc_class_active(cls, ifc_class): pass def is_ifc_object(cls, obj): pass def remove_object(cls, obj): pass diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index a9b376f389..fac9657dbd 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -347,6 +347,15 @@ class Blender(bonsai.core.tool.Blender): if area.type == "VIEW_3D": return area + @classmethod + def operator_idname_to_py(cls, idname: str) -> str: + """Convert a Blender internal operator idname to its Python equivalent. + + Example: ``MESH_OT_primitive_cube_add`` -> ``mesh.primitive_cube_add`` + """ + module, func = idname.split("_OT_", 1) + return f"{module.lower()}.{func}" + @classmethod def get_view3d_space(cls) -> Union[bpy.types.SpaceView3D, None]: if area := cls.get_view3d_area(): diff --git a/src/bonsai/bonsai/tool/misc.py b/src/bonsai/bonsai/tool/misc.py index f73970a508..5676e8f76c 100644 --- a/src/bonsai/bonsai/tool/misc.py +++ b/src/bonsai/bonsai/tool/misc.py @@ -18,7 +18,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Union +import ctypes +from typing import TYPE_CHECKING, Any, NamedTuple, Union import bmesh import bpy @@ -37,6 +38,155 @@ if TYPE_CHECKING: class Misc(bonsai.core.tool.Misc): + + class BlenderCStructs: + + class ListBase(ctypes.Structure): + _fields_ = [("first", ctypes.c_void_p), ("last", ctypes.c_void_p)] + + class bUserMenu(ctypes.Structure): + pass + + bUserMenu._fields_ = [ + ("next", ctypes.c_void_p), + ("prev", ctypes.c_void_p), + ("space_type", ctypes.c_int8), + ("_pad0", ctypes.c_int8 * 7), + ("context", ctypes.c_char * 64), + ("items", ListBase), + ] + + class bUserMenuItem(ctypes.Structure): + _fields_ = [ + ("next", ctypes.c_void_p), + ("prev", ctypes.c_void_p), + ("ui_name", ctypes.c_char * 64), + ("type", ctypes.c_int8), + ("_pad0", ctypes.c_int8 * 7), + ] + + class bUserMenuItem_Op(ctypes.Structure): + pass + + bUserMenuItem_Op._fields_ = [ + ("item", bUserMenuItem), + ("op_idname", ctypes.c_char * 64), + ("prop", ctypes.c_void_p), + ("op_prop_enum", ctypes.c_char * 64), + ("opcontext", ctypes.c_int8), + ("_pad0", ctypes.c_int8 * 7), + ] + + class IDPropertyData(ctypes.Structure): + pass + + IDPropertyData._fields_ = [ + ("pointer", ctypes.c_void_p), + ("group", ListBase), + ("children_map", ctypes.c_void_p), + ("val", ctypes.c_int), + ("val2", ctypes.c_int), + ] + + class IDProperty(ctypes.Structure): + pass + + IDProperty._fields_ = [ + ("next", ctypes.c_void_p), + ("prev", ctypes.c_void_p), + ("type", ctypes.c_int8), + ("subtype", ctypes.c_int8), + ("flag", ctypes.c_int16), + ("name", ctypes.c_char * 64), + ("_pad0", ctypes.c_int8 * 4), + ("data", IDPropertyData), + ("len", ctypes.c_int), + ("totallen", ctypes.c_int), + ("ui_data", ctypes.c_void_p), + ] + + class QuickFavorites: + """Blender doesn't provide a good way to access or manage Quick Favorites + from the Python API. We use c-structs (ctypes) to read data directly from memory. + This is fragile and can break between Blender versions. We only use this for + reading data and never writing, to avoid the possibility of corrupting user preferences. + """ + + OFFSET_USER_MENUS: dict[tuple[int, int], int] = { + (4, 5): 10032, + (5, 0): 10032, + (5, 1): 10032, + } + + @classmethod + def _read_idprop_value(cls, prop_ptr: int) -> Any: + IDP_STRING = 0 + IDP_INT = 1 + IDP_FLOAT = 2 + IDP_BOOLEAN = 10 + + p = Misc.BlenderCStructs.IDProperty.from_address(prop_ptr) + if p.type == IDP_INT: + return p.data.val + elif p.type == IDP_BOOLEAN: + return bool(p.data.val) + elif p.type == IDP_FLOAT: + return ctypes.c_float.from_buffer_copy(ctypes.c_int(p.data.val)).value + elif p.type == IDP_STRING: + return ctypes.string_at(p.data.pointer).decode() + return f"" + + @classmethod + def _read_idprop_group(cls, group_ptr: int) -> dict[str, Any]: + root = Misc.BlenderCStructs.IDProperty.from_address(group_ptr) + result: dict[str, Any] = {} + child_ptr = root.data.group.first + while child_ptr: + child = Misc.BlenderCStructs.IDProperty.from_address(child_ptr) + result[child.name.decode()] = cls._read_idprop_value(child_ptr) + child_ptr = child.next + return result + + class QuickFavoritesOperator(NamedTuple): + ui_name: str + op_idname_py: str + props: dict[str, Any] + + @classmethod + def get_quick_favorites(cls) -> list[QuickFavoritesOperator]: + assert bpy.context.preferences + blender_version = bpy.app.version[:2] + offset = cls.OFFSET_USER_MENUS[blender_version] + prefs_address = bpy.context.preferences.as_pointer() + user_menus = Misc.BlenderCStructs.ListBase.from_address(prefs_address + offset) + + result: list[cls.QuickFavoritesOperator] = [] + SPACE_VIEW3D = 4 + + node = user_menus.first + while node: + user_menu = Misc.BlenderCStructs.bUserMenu.from_address(node) + if user_menu.space_type != SPACE_VIEW3D: + node = user_menu.next + continue + item_ptr = user_menu.items.first + while item_ptr: + umi = Misc.BlenderCStructs.bUserMenuItem.from_address(item_ptr) + if umi.type == 2: # OPERATOR + op = Misc.BlenderCStructs.bUserMenuItem_Op.from_address(item_ptr) + props = cls._read_idprop_group(op.prop) if op.prop else {} + result.append( + cls.QuickFavoritesOperator( + ui_name=op.item.ui_name.decode(), + op_idname_py=tool.Blender.operator_idname_to_py(op.op_idname.decode()), + props=props, + ) + ) + item_ptr = umi.next + node = user_menu.next + + return result + @classmethod def get_misc_props(cls) -> BIMMiscProperties: return bpy.context.scene.BIMMiscProperties diff --git a/src/bonsai/test/tool/test_misc.py b/src/bonsai/test/tool/test_misc.py index c3d3cec2fe..21c1fd341c 100644 --- a/src/bonsai/test/tool/test_misc.py +++ b/src/bonsai/test/tool/test_misc.py @@ -171,6 +171,12 @@ class TestScaleObjectToHeight(test.bim.bootstrap.NewFile): assert list(obj.scale) == [1.0, 1.0, 1.0] +class TestQuickFavoritesOffsetUserMenus(test.bim.bootstrap.NewFile): + def test_current_blender_version_is_supported(self): + version = bpy.app.version[:2] + assert version in subject.QuickFavorites.OFFSET_USER_MENUS + + class TestSplitObjectsWithCutter(test.bim.bootstrap.NewFile): def test_run(self): bpy.ops.mesh.primitive_cube_add()