From be05d771a264fd73042e12ff6ce6db0a3cf9bdd6 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Mon, 30 Mar 2026 07:25:22 +0100 Subject: [PATCH] api.boundary.edit_attributes: add PhysicalOrVirtualBoundary and InternalOrExternalBoundary params Both attributes are required by the IFC schema but were not settable via the API function. Add physical_or_virtual and internal_or_external parameters with "NOTDEFINED" defaults for backward compatibility. Update Bonsai boundary panel to expose both fields in the editor. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/boundary/operator.py | 6 + src/bonsai/bonsai/bim/module/boundary/prop.py | 34 ++++++ src/bonsai/bonsai/bim/module/boundary/ui.py | 11 ++ .../api/boundary/edit_attributes.py | 18 +-- .../test/api/boundary/test_edit_attributes.py | 115 ++++++++++++++++++ 5 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py diff --git a/src/bonsai/bonsai/bim/module/boundary/operator.py b/src/bonsai/bonsai/bim/module/boundary/operator.py index 2c6a6de2cf..45ee8c60e2 100644 --- a/src/bonsai/bonsai/bim/module/boundary/operator.py +++ b/src/bonsai/bonsai/bim/module/boundary/operator.py @@ -377,6 +377,8 @@ class EnableEditingBoundary(bpy.types.Operator): obj = tool.Ifc.get_object(entity) if entity and obj: setattr(bprops, blender_property, obj) + bprops.physical_or_virtual = boundary.PhysicalOrVirtualBoundary or "NOTDEFINED" + bprops.internal_or_external = boundary.InternalOrExternalBoundary or "NOTDEFINED" return {"FINISHED"} @@ -392,6 +394,8 @@ class DisableEditingBoundary(bpy.types.Operator): bprops.is_editing = False for ifc_attribute, blender_property in EDITABLE_ATTRIBUTES.items(): setattr(bprops, blender_property, None) + bprops.physical_or_virtual = "NOTDEFINED" + bprops.internal_or_external = "NOTDEFINED" return {"FINISHED"} @@ -411,6 +415,8 @@ class EditBoundaryAttributes(bpy.types.Operator, tool.Ifc.Operator): obj = getattr(bprops, blender_property, None) entity = tool.Ifc.get_entity(obj) attributes[blender_property] = entity + attributes["physical_or_virtual"] = bprops.physical_or_virtual + attributes["internal_or_external"] = bprops.internal_or_external ifcopenshell.api.boundary.edit_attributes(tool.Ifc.get(), entity=boundary, **attributes) bpy.ops.bim.disable_editing_boundary() return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/boundary/prop.py b/src/bonsai/bonsai/bim/module/boundary/prop.py index 2e9ab8c975..7b4f5069ad 100644 --- a/src/bonsai/bonsai/bim/module/boundary/prop.py +++ b/src/bonsai/bonsai/bim/module/boundary/prop.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Union import bpy from bpy.props import ( BoolProperty, + EnumProperty, PointerProperty, ) from bpy.types import PropertyGroup @@ -50,12 +51,43 @@ def element_filter(self: "BIMObjectBoundaryProperties", object: bpy.types.Object return False +def get_internal_or_external_items( + self: "BIMObjectBoundaryProperties", context: bpy.types.Context | None +) -> list[tuple[str, str, str]]: + items = [ + ("INTERNAL", "Internal", ""), + ("EXTERNAL", "External", ""), + ] + ifc = tool.Ifc.get() + if not ifc or ifc.schema != "IFC2X3": + items += [ + ("EXTERNAL_EARTH", "External Earth", ""), + ("EXTERNAL_WATER", "External Water", ""), + ("EXTERNAL_FIRE", "External Fire", ""), + ] + items.append(("NOTDEFINED", "Not Defined", "")) + return items + + class BIMObjectBoundaryProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing") relating_space: PointerProperty(name="RelatingSpace", type=bpy.types.Object, poll=space_filter) related_building_element: PointerProperty(name="RelatedBuildingElement", type=bpy.types.Object, poll=element_filter) parent_boundary: PointerProperty(name="ParentBoundary", type=bpy.types.Object, poll=boundary_filter) corresponding_boundary: PointerProperty(name="CorrespondingBoundary", type=bpy.types.Object, poll=boundary_filter) + physical_or_virtual: EnumProperty( + name="PhysicalOrVirtualBoundary", + items=[ + ("PHYSICAL", "Physical", ""), + ("VIRTUAL", "Virtual", ""), + ("NOTDEFINED", "Not Defined", ""), + ], + default="NOTDEFINED", + ) + internal_or_external: EnumProperty( + name="InternalOrExternalBoundary", + items=get_internal_or_external_items, + ) if TYPE_CHECKING: is_editing: bool @@ -63,6 +95,8 @@ class BIMObjectBoundaryProperties(PropertyGroup): related_building_element: Union[bpy.types.Object, None] parent_boundary: Union[bpy.types.Object, None] corresponding_boundary: Union[bpy.types.Object, None] + physical_or_virtual: str + internal_or_external: str # values depend on schema: IFC2X3 omits EXTERNAL_EARTH/WATER/FIRE class BIMBoundaryProperties(PropertyGroup): diff --git a/src/bonsai/bonsai/bim/module/boundary/ui.py b/src/bonsai/bonsai/bim/module/boundary/ui.py index 0ca21b9ce5..91990a5eb0 100644 --- a/src/bonsai/bonsai/bim/module/boundary/ui.py +++ b/src/bonsai/bonsai/bim/module/boundary/ui.py @@ -77,6 +77,10 @@ class BIM_PT_Boundary(Panel): self.draw_relation_editor(boundary, "RelatedBuildingElement", "related_building_element") self.draw_relation_editor(boundary, "ParentBoundary", "parent_boundary") self.draw_relation_editor(boundary, "CorrespondingBoundary", "corresponding_boundary") + row = self.layout.row() + row.prop(self.bprops, "physical_or_virtual") + row = self.layout.row() + row.prop(self.bprops, "internal_or_external") else: row = self.layout.row() row.operator("bim.enable_editing_boundary", icon="GREASEPENCIL", text="Edit") @@ -84,6 +88,8 @@ class BIM_PT_Boundary(Panel): self.draw_relation_data(boundary, "RelatedBuildingElement") self.draw_relation_data(boundary, "ParentBoundary") self.draw_relation_data(boundary, "CorrespondingBoundary") + self.draw_enum_data(boundary, "PhysicalOrVirtualBoundary") + self.draw_enum_data(boundary, "InternalOrExternalBoundary") if hasattr(boundary, "InnerBoundaries"): for i, inner_boundary in enumerate(getattr(boundary, "InnerBoundaries", ())): row = self.layout.row(align=True) @@ -110,6 +116,11 @@ class BIM_PT_Boundary(Panel): else: row.label(text="") + def draw_enum_data(self, boundary, ifc_attribute: str): + row = self.layout.row(align=True) + row.label(text=ifc_attribute) + row.label(text=getattr(boundary, ifc_attribute, "") or "") + def draw_relation_editor(self, boundary, ifc_attribute: str, blender_property: str): if hasattr(boundary, ifc_attribute): row = self.layout.row(align=True) diff --git a/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py b/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py index ee11094d13..4fa5516bc6 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py +++ b/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py @@ -27,12 +27,11 @@ def edit_attributes( related_building_element: ifcopenshell.entity_instance, parent_boundary: Optional[ifcopenshell.entity_instance] = None, corresponding_boundary: Optional[ifcopenshell.entity_instance] = None, + physical_or_virtual: str = "NOTDEFINED", + internal_or_external: str = "NOTDEFINED", ) -> None: """Modify the relationships of a space boundary relationship - Currently this function is quite minimal and offers no advantage to - manual assignment of the space boundary attributes. - :param entity: The IfcRelSpaceBoundary to modify :param relating_space: The IfcSpace or IfcExternalSpatialElement that the space boundary is related to. @@ -44,17 +43,18 @@ def edit_attributes( :param corresponding_boundary: The other IfcRelSpaceBoundary on the other side of the related element. The pair together represents a thermal boundary. This only applies to 2nd level boundaries. + :param physical_or_virtual: IfcPhysicalOrVirtualEnum value: "PHYSICAL", + "VIRTUAL", or "NOTDEFINED". + :param internal_or_external: IfcInternalOrExternalEnum value: + "INTERNAL", "EXTERNAL", "EXTERNAL_EARTH", "EXTERNAL_WATER", + "EXTERNAL_FIRE", or "NOTDEFINED". :return: None """ - entity = entity - relating_space = relating_space - related_building_element = related_building_element - parent_boundary = parent_boundary - corresponding_boundary = corresponding_boundary - entity.RelatingSpace = relating_space entity.RelatedBuildingElement = related_building_element if hasattr(entity, "ParentBoundary"): entity.ParentBoundary = parent_boundary if hasattr(entity, "CorrespondingBoundary"): entity.CorrespondingBoundary = corresponding_boundary + entity.PhysicalOrVirtualBoundary = physical_or_virtual + entity.InternalOrExternalBoundary = internal_or_external diff --git a/src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py b/src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py new file mode 100644 index 0000000000..ed79d7e8bf --- /dev/null +++ b/src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py @@ -0,0 +1,115 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell.api.boundary +import ifcopenshell.api.root +import test.bootstrap + + +class TestEditAttributes(test.bootstrap.IFC4): + def setup_boundary(self): + space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace") + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + boundary = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcRelSpaceBoundary") + return boundary, space, wall + + def test_sets_relating_space_and_building_element(self): + boundary, space, wall = self.setup_boundary() + ifcopenshell.api.boundary.edit_attributes( + self.file, entity=boundary, relating_space=space, related_building_element=wall + ) + assert boundary.RelatingSpace == space + assert boundary.RelatedBuildingElement == wall + + def test_defaults_enums_to_notdefined(self): + boundary, space, wall = self.setup_boundary() + ifcopenshell.api.boundary.edit_attributes( + self.file, entity=boundary, relating_space=space, related_building_element=wall + ) + assert boundary.PhysicalOrVirtualBoundary == "NOTDEFINED" + assert boundary.InternalOrExternalBoundary == "NOTDEFINED" + + def test_sets_physical_or_virtual(self): + boundary, space, wall = self.setup_boundary() + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + physical_or_virtual="PHYSICAL", + ) + assert boundary.PhysicalOrVirtualBoundary == "PHYSICAL" + + def test_sets_internal_or_external(self): + boundary, space, wall = self.setup_boundary() + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + internal_or_external="EXTERNAL", + ) + assert boundary.InternalOrExternalBoundary == "EXTERNAL" + + def test_sets_all_enum_variants(self): + boundary, space, wall = self.setup_boundary() + for value in ("PHYSICAL", "VIRTUAL", "NOTDEFINED"): + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + physical_or_virtual=value, + ) + assert boundary.PhysicalOrVirtualBoundary == value + + for value in ("INTERNAL", "EXTERNAL", "EXTERNAL_EARTH", "EXTERNAL_WATER", "EXTERNAL_FIRE", "NOTDEFINED"): + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + internal_or_external=value, + ) + assert boundary.InternalOrExternalBoundary == value + + +class TestEditAttributesIFC2X3(test.bootstrap.IFC2X3, TestEditAttributes): + def test_sets_all_enum_variants(self): + boundary, space, wall = self.setup_boundary() + for value in ("PHYSICAL", "VIRTUAL", "NOTDEFINED"): + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + physical_or_virtual=value, + ) + assert boundary.PhysicalOrVirtualBoundary == value + + # IFC2X3 only has INTERNAL, EXTERNAL, NOTDEFINED + for value in ("INTERNAL", "EXTERNAL", "NOTDEFINED"): + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + internal_or_external=value, + ) + assert boundary.InternalOrExternalBoundary == value