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()