From 628a76e487458b5e7a08a7a5f2bace1859eacb7c Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Fri, 9 Jan 2026 02:02:43 +0100 Subject: [PATCH] Simplified handling of Ports in MEP --- .../bonsai/bim/module/system/__init__.py | 3 + .../bonsai/bim/module/system/operator.py | 96 +++++++++- src/bonsai/bonsai/bim/module/system/prop.py | 60 +++++- src/bonsai/bonsai/bim/module/system/ui.py | 175 ++++++++++++------ src/bonsai/bonsai/core/system.py | 5 +- src/bonsai/bonsai/tool/system.py | 26 +++ 6 files changed, 300 insertions(+), 65 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/system/__init__.py b/src/bonsai/bonsai/bim/module/system/__init__.py index 92782ae6fe..4c805f282d 100644 --- a/src/bonsai/bonsai/bim/module/system/__init__.py +++ b/src/bonsai/bonsai/bim/module/system/__init__.py @@ -21,11 +21,13 @@ from . import ui, prop, operator, decorator classes = ( operator.AddPort, + operator.AddRelatedPortConnection, operator.AddSystem, operator.AddZone, operator.AssignSystem, operator.AssignUnassignFlowControl, operator.ConnectPort, + operator.CycleFlowDirection, operator.DisableEditingSystem, operator.DisableEditingZone, operator.DisableSystemEditingUI, @@ -43,6 +45,7 @@ classes = ( operator.RemoveZone, operator.SelectSystemProducts, operator.SetFlowDirection, + operator.ShowPortFlowError, operator.ShowPorts, operator.UnassignSystem, operator.UnloadZones, diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index add214c09d..9084f1a6b8 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -217,7 +217,7 @@ class HidePorts(bpy.types.Operator, tool.Ifc.Operator): class AddPort(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_port" - bl_description = "Add port at current cursor position" + bl_description = "Add USERDEFINED port at current cursor position" bl_label = "Add Port" bl_options = {"REGISTER", "UNDO"} @@ -249,6 +249,42 @@ class ConnectPort(bpy.types.Operator, tool.Ifc.Operator): core.connect_port(tool.Ifc, port1=tool.Ifc.get_entity(obj1), port2=tool.Ifc.get_entity(obj2)) +class AddRelatedPortConnection(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_related_port_connection" + bl_label = "Connect Port" + bl_description = "Click to select a port to connect to" + bl_options = {"REGISTER", "UNDO"} + + element_id: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"}) + + def invoke(self, context, event): + if self.element_id == 0: + self.report({'ERROR'}, "No port specified") + return {'CANCELLED'} + context.window_manager.modal_handler_add(self) + return {'RUNNING_MODAL'} + + def modal(self, context, event): + if event.type == 'LEFTMOUSE' and event.value == 'PRESS': + if context.active_object: + target_element = tool.Ifc.get_entity(context.active_object) + if target_element and target_element.is_a("IfcDistributionPort"): + source_element = tool.Ifc.get().by_id(self.element_id) + core.connect_port(tool.Ifc, port1=source_element, port2=target_element) + self.report({'INFO'}, "Ports connected") + return {'FINISHED'} + else: + self.report({'WARNING'}, "Selected object is not a port") + return {'RUNNING_MODAL'} + elif event.type in {'RIGHTMOUSE', 'ESC'}: + self.report({'INFO'}, "Cancelled") + return {'CANCELLED'} + return {'RUNNING_MODAL'} + + def _execute(self, context): + return {'CANCELLED'} + + class DisconnectPort(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.disconnect_port" bl_label = "Disconnect Ports" @@ -256,6 +292,9 @@ class DisconnectPort(bpy.types.Operator, tool.Ifc.Operator): element_id: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"}) + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + def _execute(self, context): if self.element_id != 0: element = tool.Ifc.get().by_id(self.element_id) @@ -379,6 +418,61 @@ class SetFlowDirection(bpy.types.Operator, tool.Ifc.Operator): return {"CANCELLED"} +class CycleFlowDirection(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.cycle_flow_direction" + bl_label = "Cycle Flow Direction" + bl_options = {"REGISTER", "UNDO"} + port_id: bpy.props.IntProperty() + + @classmethod + def description(cls, context, operator): + try: + port = tool.Ifc.get().by_id(operator.port_id) + if port and port.is_a("IfcDistributionPort"): + current_direction = port.FlowDirection or "NOTDEFINED" + return f"Current flow direction: {current_direction}. Click to cycle: SOURCE → SINK → SOURCEANDSINK → NOTDEFINED" + except: + pass + return "Cycle through flow directions: SOURCE → SINK → SOURCEANDSINK → NOTDEFINED → SOURCE..." + + def _execute(self, context): + port = tool.Ifc.get().by_id(self.port_id) + if not port or not port.is_a("IfcDistributionPort"): + return {"CANCELLED"} + + current_direction = port.FlowDirection or "NOTDEFINED" + flow_cycle = ["SOURCE", "SINK", "SOURCEANDSINK", "NOTDEFINED"] + + try: + current_index = flow_cycle.index(current_direction) + next_direction = flow_cycle[(current_index + 1) % len(flow_cycle)] + except ValueError: + next_direction = "SOURCE" + + tool.Ifc.run("attribute.edit_attributes", product=port, attributes={"FlowDirection": next_direction}) + + PortData.is_loaded = False + + return {"FINISHED"} + + +class ShowPortFlowError(bpy.types.Operator): + bl_idname = "bim.show_port_flow_error" + bl_label = "" + bl_options = {"REGISTER"} + port_flow: bpy.props.StringProperty() + connected_flow: bpy.props.StringProperty() + + @classmethod + def description(cls, context, operator): + if operator.port_flow and operator.connected_flow: + return f"Semantic error: Incompatible flow directions ({operator.port_flow} connected to {operator.connected_flow})" + return "Semantic error: Incompatible flow directions" + + def execute(self, context): + return {"FINISHED"} + + class LoadZones(bpy.types.Operator): bl_idname = "bim.load_zones" bl_label = "Load Zones" diff --git a/src/bonsai/bonsai/bim/module/system/prop.py b/src/bonsai/bonsai/bim/module/system/prop.py index a603446c9f..6b82d650af 100644 --- a/src/bonsai/bonsai/bim/module/system/prop.py +++ b/src/bonsai/bonsai/bim/module/system/prop.py @@ -19,7 +19,8 @@ import bpy import bonsai.bim.handler import bonsai.tool as tool -from bonsai.bim.module.system.data import SystemData +import bonsai.core.system as core +from bonsai.bim.module.system.data import SystemData, PortData import bonsai.bim.module.system.decorator as decorator from bonsai.bim.prop import StrProperty, Attribute from bpy.types import PropertyGroup @@ -92,6 +93,56 @@ def toggle_decorations(self: "BIMSystemProperties", context: bpy.types.Context) decorator.SystemDecorator.uninstall() +def is_port_available_for_connection(self: "BIMSystemProperties", obj: bpy.types.Object) -> bool: + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcDistributionPort"): + return False + connected_port = tool.System.get_connected_port(element) + if connected_port is not None: + return False + + if bpy.context.active_object: + active_element = tool.Ifc.get_entity(bpy.context.active_object) + if active_element: + active_ports = tool.System.get_ports(active_element) + if element in active_ports: + return False + + return True + + +def update_related_port_object(self: "BIMSystemProperties", context: bpy.types.Context) -> None: + if self.related_port_object is None: + return + if not context.active_object: + return + + source_element = tool.Ifc.get_entity(context.active_object) + target_element = tool.Ifc.get_entity(self.related_port_object) + + if not source_element or not target_element: + self.related_port_object = None + return + + if not target_element.is_a("IfcDistributionPort"): + self.related_port_object = None + return + + source_port = None + for port in tool.System.get_ports(source_element): + if not tool.System.get_connected_port(port): + source_port = port + break + + if not source_port: + self.related_port_object = None + return + + core.connect_port(tool.Ifc, port1=source_port, port2=target_element) + self.related_port_object = None + PortData.is_loaded = False + + class BIMSystemProperties(PropertyGroup): system_attributes: CollectionProperty(name="System Attributes", type=Attribute) is_editing: BoolProperty(name="Is Editing", default=False) @@ -107,6 +158,12 @@ class BIMSystemProperties(PropertyGroup): should_draw_decorations: BoolProperty( name="Should Draw Decorations", description="Toggle system decorations", update=toggle_decorations ) + related_port_object: PointerProperty( + type=bpy.types.Object, + name="Connect To Port", + update=update_related_port_object, + poll=is_port_available_for_connection, + ) if TYPE_CHECKING: system_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] @@ -119,6 +176,7 @@ class BIMSystemProperties(PropertyGroup): edited_system_id: int system_class: str should_draw_decorations: bool + related_port_object: Union[bpy.types.Object, None] @property def active_system_ui_item(self) -> Union[System, None]: diff --git a/src/bonsai/bonsai/bim/module/system/ui.py b/src/bonsai/bonsai/bim/module/system/ui.py index 2d555f24c2..3574f95469 100644 --- a/src/bonsai/bonsai/bim/module/system/ui.py +++ b/src/bonsai/bonsai/bim/module/system/ui.py @@ -162,45 +162,75 @@ class BIM_PT_ports(Panel): if total_ports == 0: return - row = self.layout.row(align=True) - row.label(text="Change Flow Direction:") - - current_flow_direction = PortData.data["selected_objects_flow_direction"] - for flow_direction in FLOW_DIRECTION_TO_ICON.keys(): - row.operator( - "bim.set_flow_direction", - icon=FLOW_DIRECTION_TO_ICON[flow_direction], - depress=flow_direction == current_flow_direction, - text="", - ).direction = flow_direction - row.enabled = len(context.selected_objects) == 2 + props = tool.System.get_system_props() row = self.layout.row(align=True) - row.label(text="Ports located on object and connected objects:") + row.label(text="Ports located on object and connected Port/Objects:") + row = self.layout.row(align=True) - cols = [row.column(align=True) for i in range(6)] + cols = [row.column(align=True) for i in range(10)] for port_data in PortData.data["located_ports_data"]: flow_direction_icon = FLOW_DIRECTION_TO_ICON[port_data["FlowDirection"] or "NOTDEFINED"] - if port_data["port_obj_name"]: - cols[0].label(text="", icon=flow_direction_icon) - cols[1].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = port_data["id"] - cols[2].label(text=port_data["port_obj_name"]) + + if port_data["connected_obj_name"]: + cols[0].operator("bim.disconnect_port", text="", icon="UNLINKED").element_id = port_data["id"] + op = cols[1].operator("bim.cycle_flow_direction", text="", icon=flow_direction_icon, emboss=True) + op.port_id = port_data["id"] else: - cols[0].label(text="", icon=flow_direction_icon) - cols[1].label(text="", icon="HIDE_ON") - cols[2].label(text="Port is hidden") + cols[0].label(text="", icon="BLANK1") + op = cols[1].operator("bim.cycle_flow_direction", text="", icon=flow_direction_icon, emboss=True) + op.port_id = port_data["id"] + + # Port information + if port_data["port_obj_name"]: + cols[2].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = port_data["id"] + cols[3].label(text=port_data["port_obj_name"]) + else: + cols[2].label(text="", icon="HIDE_ON") + cols[3].label(text="Port is hidden") if port_data["connected_obj_name"]: connected_obj = bpy.data.objects[port_data["connected_obj_name"]] - cols[3].operator("bim.disconnect_port", text="", icon="UNLINKED").element_id = port_data["id"] + + port = tool.Ifc.get().by_id(port_data["id"]) + connected_port = tool.System.get_connected_port(port) + if connected_port: + connected_port_flow_dir = FLOW_DIRECTION_TO_ICON[connected_port.FlowDirection or "NOTDEFINED"] + op = cols[4].operator("bim.cycle_flow_direction", text="", icon=connected_port_flow_dir, emboss=True) + op.port_id = connected_port.id() + cols[5].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = connected_port.id() + connected_port_obj = tool.Ifc.get_object(connected_port) + cols[6].label(text=connected_port_obj.name if connected_port_obj else "Hidden Port") + else: + cols[4].label(text="", icon="BLANK1") + cols[5].label(text="", icon="BLANK1") + cols[6].label(text="") + ifc_id = tool.Blender.get_ifc_definition_id(connected_obj) - cols[4].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id - cols[5].label(text=port_data["connected_obj_name"]) + cols[7].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id + cols[8].label(text=port_data["connected_obj_name"]) + + if connected_port: + port_flow = port.FlowDirection or "NOTDEFINED" + connected_flow = connected_port.FlowDirection or "NOTDEFINED" + if (port_flow == "SOURCE" and connected_flow == "SOURCE") or \ + (port_flow == "SINK" and connected_flow == "SINK"): + op = cols[9].operator("bim.show_port_flow_error", text="", icon="ERROR", emboss=False) + op.port_flow = port_flow + op.connected_flow = connected_flow + else: + cols[9].label(text="", icon="BLANK1") + else: + cols[9].label(text="", icon="BLANK1") else: - cols[3].label(text="", icon="UNLINKED") cols[4].label(text="", icon="BLANK1") - cols[5].label(text="Port is disconnected") + cols[5].label(text="", icon="BLANK1") + cols[6].alignment = 'LEFT' + cols[6].prop(props, "related_port_object", text="") + cols[7].label(text="", icon="BLANK1") + cols[8].label(text="Port is disconnected") + cols[9].label(text="", icon="BLANK1") class BIM_PT_port(Panel): @@ -227,7 +257,12 @@ class BIM_PT_port(Panel): layout = self.layout row = layout.row(align=True) - row.label(text="IfcDistributionPort") + + if not PortData.is_loaded: + PortData.load() + + relating_object_name = PortData.data["port_relating_object_name"] if PortData.data["is_port"] else "" + row.label(text=f"IfcDistributionPort located in: {relating_object_name}") row.operator("bim.connect_port", icon="PLUGIN", text="") row.operator("bim.disconnect_port", icon="UNLINKED", text="") row.operator("bim.remove_port", icon="X", text="") @@ -239,41 +274,61 @@ class BIM_PT_port(Panel): return element = tool.Ifc.get_entity(context.active_object) - current_flow_direction = str(element.FlowDirection) + props = tool.System.get_system_props() + row = layout.row(align=True) - row.label(text="Flow Direction:") - row.label(text=current_flow_direction) - - # port located on - row = layout.row(align=True) - relating_object_name = PortData.data["port_relating_object_name"] - relating_object = bpy.data.objects[relating_object_name] - row.label(text="Port located on:") - row.label(text=relating_object_name) - ifc_id = tool.Blender.get_ifc_definition_id(relating_object) - row.operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id - - # object connected to the port - row = layout.row(align=True) - connected_object_name = PortData.data["port_connected_object_name"] - if connected_object_name: - connected_object = bpy.data.objects[connected_object_name] - row.label(text="Port connected to:") - row.label(text=connected_object_name) - ifc_id = tool.Blender.get_ifc_definition_id(connected_object) - row.operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id + cols = [row.column(align=True) for i in range(10)] + + flow_direction_icon = FLOW_DIRECTION_TO_ICON[element.FlowDirection or "NOTDEFINED"] + connected_port = tool.System.get_connected_port(element) + + if connected_port: + cols[0].operator("bim.disconnect_port", text="", icon="UNLINKED") + op = cols[1].operator("bim.cycle_flow_direction", text="", icon=flow_direction_icon, emboss=True) + op.port_id = element.id() else: - row.label(text="Port is not connected to any element") - - row = layout.row(align=True) - row.label(text="Change Flow Direction:") - for flow_direction in FLOW_DIRECTION_TO_ICON.keys(): - row.operator( - "bim.set_flow_direction", - icon=FLOW_DIRECTION_TO_ICON[flow_direction], - depress=flow_direction == current_flow_direction, - text="", - ).direction = flow_direction + cols[0].label(text="", icon="BLANK1") + op = cols[1].operator("bim.cycle_flow_direction", text="", icon=flow_direction_icon, emboss=True) + op.port_id = element.id() + + cols[2].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = element.id() + cols[3].label(text=context.active_object.name) + + if connected_port: + connected_port_flow_dir = FLOW_DIRECTION_TO_ICON[connected_port.FlowDirection or "NOTDEFINED"] + op = cols[4].operator("bim.cycle_flow_direction", text="", icon=connected_port_flow_dir, emboss=True) + op.port_id = connected_port.id() + cols[5].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = connected_port.id() + connected_port_obj = tool.Ifc.get_object(connected_port) + cols[6].label(text=connected_port_obj.name if connected_port_obj else "Hidden Port") + + connected_object_name = PortData.data["port_connected_object_name"] + if connected_object_name: + connected_obj = bpy.data.objects[connected_object_name] + ifc_id = tool.Blender.get_ifc_definition_id(connected_obj) + cols[7].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id + cols[8].label(text=connected_object_name) + else: + cols[7].label(text="", icon="BLANK1") + cols[8].label(text="") + + port_flow = element.FlowDirection or "NOTDEFINED" + connected_flow = connected_port.FlowDirection or "NOTDEFINED" + if (port_flow == "SOURCE" and connected_flow == "SOURCE") or \ + (port_flow == "SINK" and connected_flow == "SINK"): + op = cols[9].operator("bim.show_port_flow_error", text="", icon="ERROR", emboss=False) + op.port_flow = port_flow + op.connected_flow = connected_flow + else: + cols[9].label(text="", icon="BLANK1") + else: + cols[4].label(text="", icon="BLANK1") + cols[5].label(text="", icon="BLANK1") + cols[6].alignment = 'LEFT' + cols[6].prop(props, "related_port_object", text="") + cols[7].label(text="", icon="BLANK1") + cols[8].label(text="Port is disconnected") + cols[9].label(text="", icon="BLANK1") class BIM_PT_flow_controls(Panel): diff --git a/src/bonsai/bonsai/core/system.py b/src/bonsai/bonsai/core/system.py index fcdb95e72d..7a1df28280 100644 --- a/src/bonsai/bonsai/core/system.py +++ b/src/bonsai/bonsai/core/system.py @@ -123,9 +123,8 @@ def hide_ports(ifc: type[tool.Ifc], system: type[tool.System], element: ifcopens def add_port(ifc: type[tool.Ifc], system: type[tool.System], element: ifcopenshell.entity_instance) -> None: system.load_ports(element, system.get_ports(element)) - obj = system.create_empty_at_cursor_with_element_orientation(element) - port = system.run_root_assign_class(obj=obj, ifc_class="IfcDistributionPort", should_add_representation=False) - ifc.run("system.assign_port", element=element, port=port) + port = system.create_port_at_cursor(element) + system.load_ports(element, [port]) def remove_port(ifc: type[tool.Ifc], system: type[tool.System], port: ifcopenshell.entity_instance) -> None: diff --git a/src/bonsai/bonsai/tool/system.py b/src/bonsai/bonsai/tool/system.py index efaaa802c5..519136a1f8 100644 --- a/src/bonsai/bonsai/tool/system.py +++ b/src/bonsai/bonsai/tool/system.py @@ -107,6 +107,32 @@ class System(bonsai.core.tool.System): bpy.context.scene.collection.objects.link(obj) return obj + @classmethod + def create_port_at_cursor(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + ifc_file = tool.Ifc.get() + element_obj = tool.Ifc.get_object(element) + + port = ifcopenshell.api.system.add_port(ifc_file, element=element) + port.FlowDirection = "NOTDEFINED" + port.PredefinedType = "USERDEFINED" + + systems = ifcopenshell.util.system.get_element_systems(element) + if systems: + system = systems[0] + if hasattr(system, "PredefinedType") and system.PredefinedType: + port.SystemType = system.PredefinedType + else: + port.SystemType = "USERDEFINED" + else: + port.SystemType = "USERDEFINED" + + matrix = element_obj.matrix_world.copy() + matrix.translation = bpy.context.scene.cursor.matrix.translation + + ifcopenshell.api.geometry.edit_object_placement(ifc_file, product=port, matrix=matrix, is_si=True) + + return port + @classmethod def delete_element_objects(cls, elements: list[ifcopenshell.entity_instance]) -> None: for element in elements: