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
This commit is contained in:
Andrej730
2026-03-10 19:05:17 +05:00
parent 578caaf2cd
commit 594d72d7e1
8 changed files with 449 additions and 5 deletions
@@ -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,
)
+147 -1
View File
@@ -16,7 +16,7 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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"
+60 -3
View File
@@ -16,25 +16,82 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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]
+67
View File
@@ -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")
+1
View File
@@ -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
+9
View File
@@ -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():
+151 -1
View File
@@ -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"<type={p.type}>"
@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
+6
View File
@@ -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()