From 5866994b84b5c344356abf386f7537e48d15ce65 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 7 Jul 2025 18:38:24 +0500 Subject: [PATCH] Intergrate explorer UI to support editing some props like #6869 Example - https://files.catbox.moe/ujhlwv.mp4 --- src/bonsai/bonsai/bim/helper.py | 23 +++-- .../bonsai/bim/module/attribute/__init__.py | 1 + .../bonsai/bim/module/attribute/operator.py | 93 +++++++++++++++++++ src/bonsai/bonsai/bim/module/attribute/ui.py | 31 ++++--- src/bonsai/bonsai/bim/prop.py | 2 + src/bonsai/bonsai/bim/ui.py | 7 ++ src/bonsai/bonsai/tool/attribute.py | 16 ++++ 7 files changed, 153 insertions(+), 20 deletions(-) diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index 58ecef6d04..93fc8b6a88 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -137,6 +137,14 @@ def draw_attribute( if attribute.is_optional: layout.prop(attribute, "is_null", icon="RADIOBUT_OFF" if attribute.is_null else "RADIOBUT_ON", text="") + if attribute.use_explorer_ui: + op = layout.operator("bim.explorer_show_ui_popup", text="", icon="ZOOM_SELECTED") + op.ifc_class = attribute.ifc_class + op.attribute_name = attribute.name + op.data_path = tool.Blender.get_full_data_path(attribute, value_name) + if ifc_id := attribute.get_value(): + op.preselect_ifc_id = int(ifc_id) + if attribute.name == "GlobalId": layout.operator("bim.generate_global_id", icon="FILE_REFRESH", text="") elif copy_operator: @@ -314,13 +322,14 @@ def export_attributes( return attributes -def process_exported_entity_attribute(attributes: dict[str, Any], attribute_name: str) -> None: - entity_id = attributes[attribute_name] - if entity_id is None: - # Maybe it was removed by now and enum is invalid. - del attributes[attribute_name] - else: - attributes[attribute_name] = tool.Ifc.get().by_id(int(entity_id)) +def process_exported_entity_attribute(attributes: dict[str, Any], attribute_names: list[str]) -> None: + for attribute_name in attribute_names: + entity_id = attributes[attribute_name] + if entity_id is None: + # Maybe it was removed by now and enum is invalid. + del attributes[attribute_name] + else: + attributes[attribute_name] = tool.Ifc.get().by_id(int(entity_id)) ENUM_ITEMS_DATA = Union[bpy.types.PropertyGroup, bpy.types.ID, bpy.types.Operator, bpy.types.OperatorProperties] diff --git a/src/bonsai/bonsai/bim/module/attribute/__init__.py b/src/bonsai/bonsai/bim/module/attribute/__init__.py index 754901042f..7348a2cffb 100644 --- a/src/bonsai/bonsai/bim/module/attribute/__init__.py +++ b/src/bonsai/bonsai/bim/module/attribute/__init__.py @@ -29,6 +29,7 @@ classes = ( operator.ExplorerEnableEditingEntity, operator.ExplorerDisableEditingEntity, operator.ExplorerEditEntity, + operator.ExplorerShowUIPopup, prop.BIMAttributeProperties, prop.ExplorerEntity, prop.BIMExplorerProperties, diff --git a/src/bonsai/bonsai/bim/module/attribute/operator.py b/src/bonsai/bonsai/bim/module/attribute/operator.py index 01d40b414c..a5e4be4216 100644 --- a/src/bonsai/bonsai/bim/module/attribute/operator.py +++ b/src/bonsai/bonsai/bim/module/attribute/operator.py @@ -79,6 +79,8 @@ class EnableEditingAttributes(bpy.types.Operator, AttributesOperator): None, ) + lookup_attrs = tool.Attribute.does_ifc_class_support_explorer_lookup(element.is_a()) + def callback(name: str, prop: Union["Attribute", None], data: dict[str, Any]) -> None | Literal[True]: if name in ("RefLatitude", "RefLongitude"): new = props.attributes.add() @@ -93,6 +95,17 @@ class EnableEditingAttributes(bpy.types.Operator, AttributesOperator): if name in ("PredefinedType", "ObjectType") and has_inherited_predefined_type: props.attributes.remove(len(props.attributes) - 1) return True + if lookup_attrs and (name in lookup_attrs): + new = props.attributes.add() + new.name = name + new.ifc_class = data["type"] + new.data_type = "enum" + new.is_optional = True + new.enum_items_dynamic = lookup_attrs[name] + new.use_explorer_ui = True + value: Union[ifcopenshell.entity_instance, None] = data[name] + if value is not None: + new.enum_value = str(value.id()) bonsai.bim.helper.import_attributes2(element, props.attributes, callback=callback) props.is_editing_attributes = True @@ -143,6 +156,9 @@ class EditAttributes(bpy.types.Operator, tool.Ifc.Operator): props = tool.Blender.get_object_attribute_props(obj) attributes = bonsai.bim.helper.export_attributes(props.attributes, callback=callback) + lookup_attrs = tool.Attribute.does_ifc_class_support_explorer_lookup(element.is_a()) + if lookup_attrs: + bonsai.bim.helper.process_exported_entity_attribute(attributes, list(lookup_attrs)) ifcopenshell.api.attribute.edit_attributes(self.file, product=element, attributes=attributes) tool.Root.set_object_name(obj, element) @@ -268,3 +284,80 @@ class ExplorerEditEntity(bpy.types.Operator, tool.Ifc.Operator): setattr(entity, attr, value) tool.Attribute.refresh_uilist_entities() tool.Attribute.disable_editing_entity() + + +class ExplorerShowUIPopup(bpy.types.Operator): + bl_idname = "bim.explorer_show_ui_popup" + bl_label = "Show Explorer UI" + bl_description = "Show Explorer UI to select element as attribute value or edit it." + bl_options = {"REGISTER", "UNDO"} + + ifc_class: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + """Element IFC class.""" + attribute_name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + """IFC class attribute name.""" + data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + """Full data path""" + preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] + """IFC id to preselect in the popup.""" + + if TYPE_CHECKING: + ifc_class: str + attribute_name: str + data_path: str + preselect_ifc_id: int + + def invoke(self, context, event) -> "set[rna_enums.OperatorReturnItems]": + assert context.window_manager + assert self.ifc_class and self.attribute_name and self.data_path + + props = tool.Attribute.get_explorer_props() + props.is_loaded = True + props.ifc_class = self.get_attribute_type() + + if self.preselect_ifc_id: + props.active_entity_index = next( + i for i, e in enumerate(props.entities) if e.ifc_definition_id == self.preselect_ifc_id + ) + + return context.window_manager.invoke_props_dialog(self, width=400) + + def get_attribute_type(self) -> str: + schema = tool.Ifc.schema() + entity = schema.declaration_by_name(self.ifc_class).as_entity() + assert entity + i = entity.attribute_index(self.attribute_name) + attr = entity.all_attributes()[i] + named_type = attr.type_of_attribute().as_named_type() + assert named_type + declared = named_type.declared_type() + return declared.name() + + def draw(self, context) -> None: + from bonsai.bim.module.attribute.ui import BIM_PT_explorer + + BIM_PT_explorer.draw(self, context, is_popup=True) + + def execute(self, context) -> "set[rna_enums.OperatorReturnItems]": + props = tool.Attribute.get_explorer_props() + active_entity = props.active_entity + if active_entity is None: + self.report({"WARNING"}, "No entity selected.") + return {"FINISHED"} + + # Apply pending changes for convenience. + if props.editing_entity_id: + if props.editing_entity_id == active_entity.ifc_definition_id: + bpy.ops.bim.explorer_edit_entity() + else: + bpy.ops.bim.explorer_disable_editing_entity() + + # Very important to do it after changes applied, otherwise enum might update + # and index will be pointing to a different element. + exec(f"{self.data_path} = '{active_entity.ifc_definition_id}'") + return {"FINISHED"} + + def cancel(self, context: bpy.types.Context) -> None: + props = tool.Attribute.get_explorer_props() + if props.editing_entity_id: + bpy.ops.bim.explorer_disable_editing_entity() diff --git a/src/bonsai/bonsai/bim/module/attribute/ui.py b/src/bonsai/bonsai/bim/module/attribute/ui.py index 975eeaccbb..1e719cba07 100644 --- a/src/bonsai/bonsai/bim/module/attribute/ui.py +++ b/src/bonsai/bonsai/bim/module/attribute/ui.py @@ -82,22 +82,24 @@ class BIM_PT_explorer(Panel): bl_context = "scene" bl_parent_id = "BIM_PT_tab_project_setup" - def draw(self, context): + def draw(self, context, *, is_popup=False): assert (layout := self.layout) props = tool.Attribute.get_explorer_props() - if not props.is_loaded: + if is_popup: + layout.label(text=props.ifc_class) + else: + if not props.is_loaded: + row = layout.row(align=True) + row.label(text="Explorer UI is not Loaded.") + row.prop(props, "is_loaded", text="", icon="IMPORT") + return + row = layout.row(align=True) - row.label(text="Explorer UI is not Loaded.") - row.prop(props, "is_loaded", text="", icon="IMPORT") - return + row.prop(props, "ifc_class", text="") + row.prop(props, "is_loaded", text="", icon="CANCEL") active_entity = props.active_entity - - row = layout.row(align=True) - row.prop(props, "ifc_class", text="") - row.prop(props, "is_loaded", text="", icon="CANCEL") - row = layout.row(align=True) row.label(text=f"{len(props.entities)} entities found") row.operator("bim.explorer_add_entity", text="", icon="ADD") @@ -113,9 +115,12 @@ class BIM_PT_explorer(Panel): if props.editing_entity_id: box = self.layout.box() - row = box.row(align=True) - row.operator("bim.explorer_edit_entity", icon="CHECKMARK") - row.operator("bim.explorer_disable_editing_entity", icon="CANCEL", text="") + # In popup we accept edits automatically for the convenience. + # Othrewise it seems very unintuitive, when you need to click confirmation twice. + if not is_popup: + row = box.row(align=True) + row.operator("bim.explorer_edit_entity", icon="CHECKMARK") + row.operator("bim.explorer_disable_editing_entity", icon="CANCEL", text="") bonsai.bim.helper.draw_attributes(props.entity_attributes, box) diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 4b025147f1..e3e43ce90f 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -344,6 +344,7 @@ class Attribute(PropertyGroup): value_max: FloatProperty(description="This is used to validate int_value and float_value") value_max_constraint: BoolProperty(default=False, description="True if the numerical value has an upper bound") special_type: StringProperty(name="Special Value Type", default="") + use_explorer_ui: BoolProperty() # pyright: ignore[reportRedeclaration] metadata: StringProperty(name="Metadata", description="For storing some additional information about the attribute") update: StringProperty(name="Update", description="Custom update function to be executed") @@ -374,6 +375,7 @@ class Attribute(PropertyGroup): value_min_constraint: bool value_max: float value_max_constraint: bool + use_explorer_ui: bool metadata: str update: str diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 1a46a8335e..fe291fb107 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -1186,6 +1186,13 @@ class EnumData: organizations = tool.Ifc.get().by_type("IfcOrganization") return natsorted(((str(e.id()), e.Name, "") for e in organizations), key=lambda x: x[1]) + @classmethod + def postal_addresses(cls) -> tool.Blender.BLENDER_ENUM_ITEMS: + addresses = tool.Ifc.get().by_type("IfcPostalAddress") + items = ((str(e.id()), (e.Description or "Undescribed"), "") for e in addresses) + items = natsorted(items, key=lambda x: x[1]) + return items + class UIData: data = {} diff --git a/src/bonsai/bonsai/tool/attribute.py b/src/bonsai/bonsai/tool/attribute.py index 26590055b0..bb1757f420 100644 --- a/src/bonsai/bonsai/tool/attribute.py +++ b/src/bonsai/bonsai/tool/attribute.py @@ -75,3 +75,19 @@ class Attribute(bonsai.core.tool.Attribute): props = cls.get_explorer_props() attributes = helper.export_attributes(props.entity_attributes) return attributes + + EXPLORER_UI_LOOKUP_PROPS: dict[str, dict[str, str]] = { + "IfcSite": {"SiteAddress": "postal_addresses"}, + "IfcBuilding": {"BuildingAddress": "postal_addresses"}, + } + """``ifc_class -> {attribute_name -> enum_items_dynamic}``""" + + @classmethod + def does_ifc_class_support_explorer_lookup(cls, ifc_class: str) -> Union[dict[str, str], None]: + """ + :return: ``{attribute_name -> enum_items_dynamic}`` + if IFC class does support explorer UI lookup, otherwise returns ``None``. + """ + if not (lookup_attrs := cls.EXPLORER_UI_LOOKUP_PROPS.get(ifc_class)): + return + return lookup_attrs