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