diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index da27e8259b..87421aa124 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -105,6 +105,8 @@ classes = [ operator.BIM_OT_select_object, operator.BIM_OT_show_description, operator.BIM_OT_multiple_file_selector, + operator.BIM_OT_attribute_add_subitem, + operator.BIM_OT_attribute_remove_subitem, operator.ClippingPlaneCutWithCappings, operator.CloseBlendWarning, operator.CloseError, diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index fc24a80e36..58ecef6d04 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -83,6 +83,22 @@ def draw_attribute( prop_with_search(layout, attribute, "enum_value", text=attribute.name) elif value_name == "filepath_value": attribute.filepath_value.layout_file_select(layout, filter_glob=attribute.filter_glob, text=attribute.name) + + elif value_name == "subitems_values": + col = layout.column() + layout = col.row(align=True) + layout.label(text=f"{attribute.name}:") + data_path = tool.Blender.get_full_data_path(attribute, value_name) + for i, item in enumerate(attribute.subitems_values, 1): + row = col.row(align=True) + row.alignment = "EXPAND" + row.prop(item, "name", text=f"# {i}") + op = row.operator("bim.attribute_remove_subitem", text="", icon="X") + op.data_path = data_path + op.index = i - 1 + op = layout.operator("bim.attribute_add_subitem", icon="ADD", text="") + op.data_path = data_path + elif attribute.name in ("ScheduleDuration", "ActualDuration", "FreeFloat", "TotalFloat"): props = tool.Sequence.get_work_schedule_props() for item in props.durations_attributes: @@ -167,6 +183,8 @@ def import_attribute( ) -> None: data_type = ifcopenshell.util.attribute.get_primitive_type(attribute) # Complex data types (aggregates and entities) are handled only by callback. + if data_type == ("list", "string"): + data_type = "list[string]" if isinstance(data_type, tuple) or data_type == "entity": callback(attribute.name(), None, data) if callback else None return @@ -177,6 +195,7 @@ def import_attribute( new.is_optional = attribute.optional() new.data_type = data_type if isinstance(data_type, str) else "" new.ifc_class = data["type"] + is_handled_by_callback = callback(attribute.name(), new, data) if callback else None data_type = new.data_type # Allow callback to override data type. @@ -222,6 +241,12 @@ def import_attribute( if enum_value is not None: new.enum_value = enum_value + elif data_type == "list[string]": + value: Union[list[str], None] = data[attribute.name()] + if value: + for item in value: + new.subitems_values.add().name = str(item).replace("\n", "\\n") + add_attribute_description(new, data) add_attribute_min_max(attribute, new) diff --git a/src/bonsai/bonsai/bim/operator.py b/src/bonsai/bonsai/bim/operator.py index 76d625d24e..e424d95f1e 100644 --- a/src/bonsai/bonsai/bim/operator.py +++ b/src/bonsai/bonsai/bim/operator.py @@ -48,6 +48,7 @@ from natsort import natsorted if TYPE_CHECKING: from bonsai.bim.prop import MultipleFileSelect, Attribute + from bpy.stub_internal import rna_enums class SetTab(bpy.types.Operator): @@ -1407,3 +1408,43 @@ class BIM_OT_attribute_search_values(bpy.types.Operator): def execute(self, context): return {"FINISHED"} + + +class BIM_OT_attribute_add_subitem(bpy.types.Operator): + bl_idname = "bim.attribute_add_subitem" + bl_label = "Add Subitem" + bl_description = "Add subitem to the current attribute" + bl_options = {"REGISTER", "UNDO"} + + data_path: bpy.props.StringProperty() + """Full data path.""" + + if TYPE_CHECKING: + data_path: str + + def execute(self, context) -> set["rna_enums.OperatorReturnItems"]: + col: "bpy.types.bpy_prop_collection_idprop[StrProperty]" + col = eval(self.data_path) + col.add() + return {"FINISHED"} + + +class BIM_OT_attribute_remove_subitem(bpy.types.Operator): + bl_idname = "bim.attribute_remove_subitem" + bl_label = "Add Subitem" + bl_description = "Add subitem to the current attribute" + bl_options = {"REGISTER", "UNDO"} + + data_path: bpy.props.StringProperty() + """Full data path.""" + index: bpy.props.IntProperty() + + if TYPE_CHECKING: + data_path: str + index: int + + def execute(self, context) -> set["rna_enums.OperatorReturnItems"]: + col: "bpy.types.bpy_prop_collection_idprop[StrProperty]" + col = eval(self.data_path) + col.remove(self.index) + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 22444ad3d2..4b025147f1 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -234,7 +234,9 @@ def update_attribute_value(self: "Attribute", context: bpy.types.Context) -> Non def update_is_null(self: "Attribute", context: bpy.types.Context) -> None: if self.is_null: - if self.data_type != "enum" and self.get_value() != (default := self.get_value_default()): + if self.data_type == "list[string]": + self.subitems_values.clear() + elif self.data_type != "enum" and self.get_value() != (default := self.get_value_default()): self.set_value(default) if self.is_null is not True: self.is_null = True @@ -286,7 +288,7 @@ def get_display_name(self: "Attribute") -> str: return f"{name}, {unit_symbol}" -AttributeDataType = Literal["string", "integer", "float", "boolean", "enum", "file"] +AttributeDataType = Literal["string", "integer", "float", "boolean", "enum", "file", "list[string]"] AttributeSpecialType = Literal["", "DATE", "DATETIME", "LENGTH", "AREA", "VOLUME", "FORCE", "LOGICAL", "URI"] @@ -300,6 +302,8 @@ class Attribute(PropertyGroup): name="Data Type", items=[(i, i, "") for i in get_args(AttributeDataType)], ) + + # Value containers. string_value: StringProperty(name="Value", update=update_attribute_value, description=tooltip) bool_value: BoolProperty(name="Value", update=update_attribute_value, description=tooltip) int_value: IntProperty( @@ -330,8 +334,11 @@ class Attribute(PropertyGroup): filepath_value: PointerProperty(type=MultipleFileSelect) filter_glob: StringProperty() is_null: BoolProperty(name="Is Null", update=update_is_null) - is_optional: BoolProperty(name="Is Optional") is_selected: BoolProperty(name="Is Selected", default=False) + subitems_values: CollectionProperty(type=StrProperty) # pyright: ignore[reportRedeclaration] + + # Attribute parameters. + is_optional: BoolProperty(name="Is Optional") value_min: FloatProperty(description="This is used to validate int_value and float_value") value_min_constraint: BoolProperty(default=False, description="True if the numerical value has a lower bound") value_max: FloatProperty(description="This is used to validate int_value and float_value") @@ -359,8 +366,10 @@ class Attribute(PropertyGroup): filepath_value: MultipleFileSelect filter_glob: str is_null: bool - is_optional: bool is_selected: bool + subitems_values: bpy.types.bpy_prop_collection_idprop[StrProperty] + + is_optional: bool value_min: float value_min_constraint: bool value_max: float @@ -368,13 +377,16 @@ class Attribute(PropertyGroup): metadata: str update: str - def get_value(self) -> Union[str, float, int, bool, None]: + def get_value(self) -> Union[str, float, int, bool, list[str], None]: if self.is_optional and self.is_null: return None if self.data_type == "string": return self.string_value.replace("\\n", "\n") if self.data_type == "file": return [f.name for f in self.filepath_value.file_list] + elif self.data_type == "list[string]": + return [s.name for s in self.subitems_values] + value_name = self.get_value_name() if value_name == "enum_value": value = tool.Blender.get_enum_safe(self, "enum_value") @@ -385,7 +397,7 @@ class Attribute(PropertyGroup): value = value == "TRUE" return value - def get_value_default(self) -> Union[str, float, int, bool]: + def get_value_default(self) -> Union[str, float, int, bool, list[str]]: data_type = self.data_type if data_type == "string": return "" @@ -399,10 +411,12 @@ class Attribute(PropertyGroup): return "0" elif data_type == "file": return "" + elif data_type == "list[string]": + return [] else: assert_never(data_type) - def get_value_name(self, display_only: bool = False) -> str: + def get_value_name(self, display_only: bool = False): """Get name of the value attribute. :param display_only: Should be `True` if the value won't be accessed directly @@ -423,6 +437,8 @@ class Attribute(PropertyGroup): return "enum_value" elif data_type == "file": return "filepath_value" + elif data_type == "list[string]": + return "subitems_values" else: assert_never(data_type) diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index cabb1bc117..18dbb6db55 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -1534,6 +1534,22 @@ class Blender(bonsai.core.tool.Blender): obj.matrix_world = matrix return True + @classmethod + def get_full_data_path(cls, bpy_struct: bpy.types.bpy_struct, path: str = "") -> str: + """Get full data path to Blender entity or it's attributes. + + :param bpy_struct: Blender entity. + :param path: Additional path to add to entity. + + :return: Path in a format + ``bpy.data.scenes['Scene'].BIMExplorerProperties.entity_attributes[4].enum_value`` + """ + if path: + bpy_prop: bpy.types.bpy_prop # pyright: ignore[reportAttributeAccessIssue] + bpy_prop = bpy_struct.path_resolve(path, False) + return repr(bpy_prop) + return repr(bpy_struct) + @classmethod def set_prop_from_path(cls, bpy_object: bpy.types.bpy_struct, prop_path: str, value: Any) -> None: """Set `data_block` property value using path from `path_from_id`."""