From 628a76e487458b5e7a08a7a5f2bace1859eacb7c Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Fri, 9 Jan 2026 02:02:43 +0100 Subject: [PATCH 01/37] 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: From 44632357286c09361204ebb104540623c61b8820 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Fri, 9 Jan 2026 08:43:46 +0100 Subject: [PATCH 02/37] Remove ShowPortFlowError operator and update flow direction handling for connected ports --- .../bonsai/bim/module/system/__init__.py | 1 - .../bonsai/bim/module/system/operator.py | 29 ++++++++----------- src/bonsai/bonsai/bim/module/system/ui.py | 29 ++----------------- 3 files changed, 14 insertions(+), 45 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/system/__init__.py b/src/bonsai/bonsai/bim/module/system/__init__.py index 4c805f282d..f44270cbe8 100644 --- a/src/bonsai/bonsai/bim/module/system/__init__.py +++ b/src/bonsai/bonsai/bim/module/system/__init__.py @@ -45,7 +45,6 @@ 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 9084f1a6b8..80c15e9799 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -451,28 +451,23 @@ class CycleFlowDirection(bpy.types.Operator, tool.Ifc.Operator): tool.Ifc.run("attribute.edit_attributes", product=port, attributes={"FlowDirection": next_direction}) + connected_port = tool.System.get_connected_port(port) + if connected_port: + # Map flow direction to connected port's complementary direction + connected_direction_map = { + "SOURCE": "SINK", + "SINK": "SOURCE", + "SOURCEANDSINK": "SOURCEANDSINK", + "NOTDEFINED": "NOTDEFINED" + } + connected_direction = connected_direction_map.get(next_direction, "NOTDEFINED") + tool.Ifc.run("attribute.edit_attributes", product=connected_port, attributes={"FlowDirection": connected_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/ui.py b/src/bonsai/bonsai/bim/module/system/ui.py index 3574f95469..24907f8650 100644 --- a/src/bonsai/bonsai/bim/module/system/ui.py +++ b/src/bonsai/bonsai/bim/module/system/ui.py @@ -168,7 +168,7 @@ class BIM_PT_ports(Panel): 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(10)] + cols = [row.column(align=True) for i in range(9)] for port_data in PortData.data["located_ports_data"]: flow_direction_icon = FLOW_DIRECTION_TO_ICON[port_data["FlowDirection"] or "NOTDEFINED"] @@ -210,19 +210,6 @@ class BIM_PT_ports(Panel): 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=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[4].label(text="", icon="BLANK1") cols[5].label(text="", icon="BLANK1") @@ -230,7 +217,6 @@ class BIM_PT_ports(Panel): 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): @@ -277,7 +263,7 @@ class BIM_PT_port(Panel): props = tool.System.get_system_props() row = layout.row(align=True) - cols = [row.column(align=True) for i in range(10)] + cols = [row.column(align=True) for i in range(9)] flow_direction_icon = FLOW_DIRECTION_TO_ICON[element.FlowDirection or "NOTDEFINED"] connected_port = tool.System.get_connected_port(element) @@ -311,16 +297,6 @@ class BIM_PT_port(Panel): 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") @@ -328,7 +304,6 @@ class BIM_PT_port(Panel): 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): From ed7ae0da9c286f63af05ff1d5ee05b71f70729ab Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Fri, 9 Jan 2026 13:18:01 +0100 Subject: [PATCH 03/37] Enhance show_ports (also shows ports of connected objects) and refine UI layout --- .../bonsai/bim/module/system/operator.py | 15 +++++- src/bonsai/bonsai/bim/module/system/prop.py | 7 +-- src/bonsai/bonsai/bim/module/system/ui.py | 54 ++++++++++++++----- 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index 80c15e9799..e60f4500ec 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -186,6 +186,7 @@ class ShowPorts(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.show_ports" bl_label = "Show Ports" bl_options = {"REGISTER", "UNDO"} + element_id: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"}) @classmethod def poll(cls, context): @@ -198,7 +199,19 @@ class ShowPorts(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): # Ifc.Operator - as operator will sync object's position with IFC. - core.show_ports(tool.Ifc, tool.System, tool.Spatial, element=tool.Ifc.get_entity(context.active_object)) + if self.element_id != 0: + element = tool.Ifc.get().by_id(self.element_id) + else: + element = tool.Ifc.get_entity(context.active_object) + core.show_ports(tool.Ifc, tool.System, tool.Spatial, element=element) + + for port in tool.System.get_ports(element): + connected_port = tool.System.get_connected_port(port) + if connected_port: + connected_port_obj = tool.Ifc.get_object(connected_port) + if not connected_port_obj: + parent_element = tool.System.get_port_relating_element(connected_port) + core.show_ports(tool.Ifc, tool.System, tool.Spatial, element=parent_element) class HidePorts(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/system/prop.py b/src/bonsai/bonsai/bim/module/system/prop.py index 6b82d650af..09d9bf5e38 100644 --- a/src/bonsai/bonsai/bim/module/system/prop.py +++ b/src/bonsai/bonsai/bim/module/system/prop.py @@ -121,11 +121,9 @@ def update_related_port_object(self: "BIMSystemProperties", context: bpy.types.C 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 @@ -135,11 +133,9 @@ def update_related_port_object(self: "BIMSystemProperties", context: bpy.types.C 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 @@ -161,8 +157,9 @@ class BIMSystemProperties(PropertyGroup): related_port_object: PointerProperty( type=bpy.types.Object, name="Connect To Port", - update=update_related_port_object, + description="Select a port to connect to. Use eyedropper to pick a port object", poll=is_port_available_for_connection, + update=update_related_port_object, ) if TYPE_CHECKING: diff --git a/src/bonsai/bonsai/bim/module/system/ui.py b/src/bonsai/bonsai/bim/module/system/ui.py index 24907f8650..dee872b463 100644 --- a/src/bonsai/bonsai/bim/module/system/ui.py +++ b/src/bonsai/bonsai/bim/module/system/ui.py @@ -169,6 +169,9 @@ class BIM_PT_ports(Panel): row = self.layout.row(align=True) cols = [row.column(align=True) for i in range(9)] + cols[3].scale_x = 1.0 + cols[6].scale_x = 1.0 + cols[8].scale_x = 1.33 for port_data in PortData.data["located_ports_data"]: flow_direction_icon = FLOW_DIRECTION_TO_ICON[port_data["FlowDirection"] or "NOTDEFINED"] @@ -178,7 +181,9 @@ class BIM_PT_ports(Panel): 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="BLANK1") + blank0 = cols[0].column(align=True) + blank0.scale_x = 0.1 + blank0.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"] @@ -196,26 +201,49 @@ class BIM_PT_ports(Panel): 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") + if connected_port_obj: + 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() + cols[6].label(text=connected_port_obj.name) + else: + blank4 = cols[4].column(align=True) + blank4.scale_x = 0.1 + blank4.label(text="", icon="BLANK1") + blank5 = cols[5].column(align=True) + blank5.scale_x = 0.1 + blank5.label(text="", icon="BLANK1") + cols[6].label(text="Port is hidden") else: - cols[4].label(text="", icon="BLANK1") - cols[5].label(text="", icon="BLANK1") + blank4 = cols[4].column(align=True) + blank4.scale_x = 0.1 + blank4.label(text="", icon="BLANK1") + blank5 = cols[5].column(align=True) + blank5.scale_x = 0.1 + blank5.label(text="", icon="BLANK1") cols[6].label(text="") 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=port_data["connected_obj_name"]) 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") + blank4 = cols[4].column(align=True) + blank4.scale_x = 0.1 + blank4.label(text="", icon="BLANK1") + blank5 = cols[5].column(align=True) + blank5.scale_x = 0.1 + blank5.label(text="", icon="BLANK1") + + col6_row = cols[6].row(align=True) + col6_row.alignment = 'LEFT' + col6_row.prop(props, "related_port_object", text="", icon='OBJECT_DATA') + col6_row.scale_x = 0.35 + + blank7 = cols[7].column(align=True) + blank7.scale_x = 0.1 + blank7.label(text="", icon="BLANK1") cols[8].label(text="Port is disconnected") From c41496a9c3f1178f3804e68f3bb1d3be577c8b25 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Fri, 9 Jan 2026 17:11:02 +0100 Subject: [PATCH 04/37] import IfcDistributionPorts with assets when executing append_library_element and for type products, use the type product's collection, not the container --- src/bonsai/bonsai/tool/system.py | 21 ++++++++++++++----- .../ifcopenshell/api/project/append_asset.py | 7 ++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/bonsai/bonsai/tool/system.py b/src/bonsai/bonsai/tool/system.py index 519136a1f8..38a93a3649 100644 --- a/src/bonsai/bonsai/tool/system.py +++ b/src/bonsai/bonsai/tool/system.py @@ -218,11 +218,22 @@ class System(bonsai.core.tool.System): ifc_importer.process_context_filter() ifc_importer.create_generic_elements(set(ports_to_create)) - container = ifcopenshell.util.element.get_container(element) - if container: - collection = tool.Blender.get_object_bim_props(tool.Ifc.get_object(container)).collection - ifc_importer.collections[container.GlobalId] = collection - ifc_importer.place_objects_in_collections() + if element.is_a("IfcTypeProduct"): + target_collection = None + for collection in obj.users_collection: + target_collection = collection + break + + if target_collection: + for port_obj in ifc_importer.added_data.values(): + if isinstance(port_obj, bpy.types.Object): + tool.Collector.link_collection_object_safe(target_collection, port_obj) + else: + container = ifcopenshell.util.element.get_container(element) + if container: + collection = tool.Blender.get_object_bim_props(tool.Ifc.get_object(container)).collection + ifc_importer.collections[container.GlobalId] = collection + ifc_importer.place_objects_in_collections() for port_obj in ifc_importer.added_data.values(): assert isinstance(port_obj, bpy.types.Object) diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py index 893b32cc02..0b703b6064 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py @@ -381,6 +381,7 @@ class Usecase: def append_type_product(self): self.whitelisted_inverse_attributes = { "IfcObjectDefinition": ["HasAssociations"], + "IfcDistributionElementType": ["IsNestedBy"], self.base_material_class: ["HasExternalReferences", "HasProperties", "HasRepresentation"], "IfcRepresentationItem": ["StyledByItem", "LayerAssignment"], "IfcRepresentation": ["LayerAssignments"], @@ -397,6 +398,7 @@ class Usecase: "IfcObjectDefinition": ["HasAssociations"], "IfcObject": ["IsDefinedBy.IfcRelDefinesByProperties"], "IfcElement": ["HasOpenings"], + "IfcDistributionElement": ["IsNestedBy"], self.base_material_class: ["HasExternalReferences", "HasProperties", "HasRepresentation"], "IfcRepresentationItem": [ "StyledByItem", @@ -488,7 +490,8 @@ class Usecase: attribute_class = None if "." in attribute: attribute, attribute_class = attribute.split(".") - for inverse in getattr(element, attribute, []): + inverse_values = getattr(element, attribute, []) + for inverse in inverse_values: if attribute_class and inverse.is_a(attribute_class): self.add_inverse_element(inverse) elif not attribute_class: @@ -569,6 +572,8 @@ class Usecase: return False elif element.is_a("IfcRoot") and self.by_guid(element.GlobalId) is not None: return False + elif element.is_a("IfcDistributionPort"): + return False elif element.is_a(self.target_class): return True elif self.target_class == "IfcProduct" and element.is_a("IfcTypeProduct"): From dfbaa6ac8ee368d5b178919a3340aaa9962c331b Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Fri, 9 Jan 2026 17:39:42 +0100 Subject: [PATCH 05/37] Reset related_port_object to None after connecting ports --- src/bonsai/bonsai/bim/module/system/prop.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bonsai/bonsai/bim/module/system/prop.py b/src/bonsai/bonsai/bim/module/system/prop.py index 09d9bf5e38..6e9681694b 100644 --- a/src/bonsai/bonsai/bim/module/system/prop.py +++ b/src/bonsai/bonsai/bim/module/system/prop.py @@ -137,6 +137,7 @@ def update_related_port_object(self: "BIMSystemProperties", context: bpy.types.C core.connect_port(tool.Ifc, port1=source_port, port2=target_element) PortData.is_loaded = False + self.related_port_object = None class BIMSystemProperties(PropertyGroup): From 7ef11361b0625b8e1e32432a60b86eca76ba058c Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Sat, 10 Jan 2026 10:12:25 +0100 Subject: [PATCH 06/37] Update flow direction icons for ports in UI --- src/bonsai/bonsai/bim/module/system/ui.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/system/ui.py b/src/bonsai/bonsai/bim/module/system/ui.py index dee872b463..6e71e5486f 100644 --- a/src/bonsai/bonsai/bim/module/system/ui.py +++ b/src/bonsai/bonsai/bim/module/system/ui.py @@ -30,10 +30,10 @@ if TYPE_CHECKING: FLOW_DIRECTION_TO_ICON = { - "SOURCE": "FORWARD", - "SINK": "BACK", - "SOURCEANDSINK": "ARROW_LEFTRIGHT", - "NOTDEFINED": "CHECKBOX_DEHLT", + "SOURCE": "FULLSCREEN_ENTER", + "SINK": "FULLSCREEN_EXIT", + "SOURCEANDSINK": "CHECKBOX_DEHLT", + "NOTDEFINED": "QUESTION", } From 0243519a68445eab760d68b954b1abd7ed8933f5 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Sun, 11 Jan 2026 14:22:58 +0100 Subject: [PATCH 07/37] Refactor port connection logic and UI integration for improved usability --- .../bonsai/bim/module/system/operator.py | 64 ++++++++--------- src/bonsai/bonsai/bim/module/system/prop.py | 71 ++++++------------- src/bonsai/bonsai/bim/module/system/ui.py | 14 ++-- src/bonsai/bonsai/tool/system.py | 10 +-- 4 files changed, 58 insertions(+), 101 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index e60f4500ec..7f298064fc 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -265,37 +265,31 @@ class ConnectPort(bpy.types.Operator, tool.Ifc.Operator): 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"}) - + + relating_port_id: bpy.props.IntProperty() + 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'} - + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + props = tool.System.get_system_props() + self.layout.prop(props, "related_port", text="Select Port") + def _execute(self, context): - return {'CANCELLED'} + props = tool.System.get_system_props() + + if not props.related_port or props.related_port == "NONE": + return {"CANCELLED"} + + port_obj = bpy.data.objects.get(props.related_port) + related_port = tool.Ifc.get_entity(port_obj) + relating_port = tool.Ifc.get().by_id(self.relating_port_id) + + core.connect_port(tool.Ifc, port1=relating_port, port2=related_port) + PortData.is_loaded = False + + return {"FINISHED"} class DisconnectPort(bpy.types.Operator, tool.Ifc.Operator): @@ -454,19 +448,19 @@ class CycleFlowDirection(bpy.types.Operator, tool.Ifc.Operator): 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" + flow_cycle_map = { + "SOURCE": "SINK", + "SINK": "SOURCEANDSINK", + "SOURCEANDSINK": "NOTDEFINED", + "NOTDEFINED": "SOURCE" + } + next_direction = flow_cycle_map.get(current_direction, "SOURCE") tool.Ifc.run("attribute.edit_attributes", product=port, attributes={"FlowDirection": next_direction}) connected_port = tool.System.get_connected_port(port) if connected_port: - # Map flow direction to connected port's complementary direction connected_direction_map = { "SOURCE": "SINK", "SINK": "SOURCE", diff --git a/src/bonsai/bonsai/bim/module/system/prop.py b/src/bonsai/bonsai/bim/module/system/prop.py index 6e9681694b..bb72fda1a9 100644 --- a/src/bonsai/bonsai/bim/module/system/prop.py +++ b/src/bonsai/bonsai/bim/module/system/prop.py @@ -34,7 +34,7 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Union, Optional def get_system_class(self: "BIMSystemProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: @@ -93,51 +93,24 @@ 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 +def get_available_ports_for_connection(self: "BIMSystemProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: + items = [] + active_object_ports = set(tool.System.get_ports(tool.Ifc.get_entity(context.active_object))) - 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 + ifc_file = tool.Ifc.get() + for ifc_port in ifc_file.by_type("IfcDistributionPort"): + port = tool.Ifc.get_object(ifc_port) + if not port: + continue + + if tool.System.get_connected_port(ifc_port) is not None or ifc_port in active_object_ports: + continue + + port_object = tool.Ifc.get_object(tool.System.get_port_relating_element(ifc_port)) + suggestion = f"{port_object.name} > {port.name}" + items.append((port.name, suggestion, "")) - 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: - return - - if not target_element.is_a("IfcDistributionPort"): - 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: - return - - core.connect_port(tool.Ifc, port1=source_port, port2=target_element) - PortData.is_loaded = False - self.related_port_object = None + return items if items else [("NONE", "Ports are hidden or not available", "")] class BIMSystemProperties(PropertyGroup): @@ -155,12 +128,10 @@ 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, + related_port: EnumProperty( name="Connect To Port", - description="Select a port to connect to. Use eyedropper to pick a port object", - poll=is_port_available_for_connection, - update=update_related_port_object, + description="Select a port to connect to", + items=get_available_ports_for_connection, ) if TYPE_CHECKING: @@ -174,7 +145,7 @@ class BIMSystemProperties(PropertyGroup): edited_system_id: int system_class: str should_draw_decorations: bool - related_port_object: Union[bpy.types.Object, None] + related_port: str @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 6e71e5486f..c4b142c31f 100644 --- a/src/bonsai/bonsai/bim/module/system/ui.py +++ b/src/bonsai/bonsai/bim/module/system/ui.py @@ -181,9 +181,8 @@ class BIM_PT_ports(Panel): op = cols[1].operator("bim.cycle_flow_direction", text="", icon=flow_direction_icon, emboss=True) op.port_id = port_data["id"] else: - blank0 = cols[0].column(align=True) - blank0.scale_x = 0.1 - blank0.label(text="", icon="BLANK1") + op = cols[0].operator("bim.add_related_port_connection", text="", icon='PLUGIN') + op.relating_port_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"] @@ -236,15 +235,14 @@ class BIM_PT_ports(Panel): blank5.scale_x = 0.1 blank5.label(text="", icon="BLANK1") - col6_row = cols[6].row(align=True) - col6_row.alignment = 'LEFT' - col6_row.prop(props, "related_port_object", text="", icon='OBJECT_DATA') - col6_row.scale_x = 0.35 + cols[6].label(text="Port is disconnected") blank7 = cols[7].column(align=True) blank7.scale_x = 0.1 blank7.label(text="", icon="BLANK1") - cols[8].label(text="Port is disconnected") + blank8 = cols[8].column(align=True) + blank8.scale_x = 0.1 + blank8.label(text="", icon="BLANK1") class BIM_PT_port(Panel): diff --git a/src/bonsai/bonsai/tool/system.py b/src/bonsai/bonsai/tool/system.py index 38a93a3649..0236b40fbd 100644 --- a/src/bonsai/bonsai/tool/system.py +++ b/src/bonsai/bonsai/tool/system.py @@ -117,14 +117,8 @@ class System(bonsai.core.tool.System): 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" + system = systems[0] if systems else None + port.SystemType = getattr(system, "PredefinedType", None) or "USERDEFINED" matrix = element_obj.matrix_world.copy() matrix.translation = bpy.context.scene.cursor.matrix.translation From e5ccd26762fe03b56e2a874da91880edfeb46d62 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Sun, 11 Jan 2026 14:46:17 +0100 Subject: [PATCH 08/37] Update port UI to indicate disconnection status more clearly --- src/bonsai/bonsai/bim/module/system/ui.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/system/ui.py b/src/bonsai/bonsai/bim/module/system/ui.py index c4b142c31f..ad8d5b5c03 100644 --- a/src/bonsai/bonsai/bim/module/system/ui.py +++ b/src/bonsai/bonsai/bim/module/system/ui.py @@ -326,10 +326,9 @@ class BIM_PT_port(Panel): 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[6].label(text="Port is disconnected") cols[7].label(text="", icon="BLANK1") - cols[8].label(text="Port is disconnected") + cols[8].label(text="") class BIM_PT_flow_controls(Panel): From 1eb2b65268989a072bb994a73821c4ed7c2f1a51 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 12 Jan 2026 12:19:13 +0100 Subject: [PATCH 09/37] Add flow direction handling to port connection logic and UI updates --- src/bonsai/bonsai/bim/module/system/operator.py | 9 ++++++--- src/bonsai/bonsai/bim/module/system/prop.py | 4 ++-- src/bonsai/bonsai/bim/module/system/ui.py | 7 ++++--- src/bonsai/bonsai/core/system.py | 4 ++-- .../ifcopenshell/api/system/disconnect_port.py | 2 -- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index 7f298064fc..049307b32f 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -259,7 +259,8 @@ class ConnectPort(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj1 = context.active_object obj2 = context.selected_objects[0] if context.selected_objects[1] == obj1 else context.selected_objects[1] - core.connect_port(tool.Ifc, port1=tool.Ifc.get_entity(obj1), port2=tool.Ifc.get_entity(obj2)) + direction = tool.Ifc.get_entity(obj1).FlowDirection or "NOTDEFINED" + core.connect_port(tool.Ifc, port1=tool.Ifc.get_entity(obj1), port2=tool.Ifc.get_entity(obj2), direction=direction) class AddRelatedPortConnection(bpy.types.Operator, tool.Ifc.Operator): @@ -286,7 +287,8 @@ class AddRelatedPortConnection(bpy.types.Operator, tool.Ifc.Operator): related_port = tool.Ifc.get_entity(port_obj) relating_port = tool.Ifc.get().by_id(self.relating_port_id) - core.connect_port(tool.Ifc, port1=relating_port, port2=related_port) + direction = relating_port.FlowDirection or "NOTDEFINED" + core.connect_port(tool.Ifc, port1=relating_port, port2=related_port, direction=direction) PortData.is_loaded = False return {"FINISHED"} @@ -356,7 +358,8 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator): ports_distance[(port1, port2)] = distance closest_ports = min(ports_distance, key=lambda x: ports_distance[x]) - core.connect_port(tool.Ifc, *closest_ports) + direction = closest_ports[0].FlowDirection or "NOTDEFINED" + core.connect_port(tool.Ifc, *closest_ports, direction=direction) bpy.ops.bim.regenerate_distribution_element() return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/system/prop.py b/src/bonsai/bonsai/bim/module/system/prop.py index bb72fda1a9..e4e56a79a0 100644 --- a/src/bonsai/bonsai/bim/module/system/prop.py +++ b/src/bonsai/bonsai/bim/module/system/prop.py @@ -20,7 +20,7 @@ import bpy import bonsai.bim.handler import bonsai.tool as tool import bonsai.core.system as core -from bonsai.bim.module.system.data import SystemData, PortData +from bonsai.bim.module.system.data import SystemData import bonsai.bim.module.system.decorator as decorator from bonsai.bim.prop import StrProperty, Attribute from bpy.types import PropertyGroup @@ -34,7 +34,7 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) -from typing import TYPE_CHECKING, Union, Optional +from typing import TYPE_CHECKING, Union def get_system_class(self: "BIMSystemProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: diff --git a/src/bonsai/bonsai/bim/module/system/ui.py b/src/bonsai/bonsai/bim/module/system/ui.py index ad8d5b5c03..5e904a3206 100644 --- a/src/bonsai/bonsai/bim/module/system/ui.py +++ b/src/bonsai/bonsai/bim/module/system/ui.py @@ -275,8 +275,6 @@ class BIM_PT_port(Panel): 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="") if not PortData.is_loaded: @@ -290,6 +288,9 @@ class BIM_PT_port(Panel): row = layout.row(align=True) cols = [row.column(align=True) for i in range(9)] + cols[3].scale_x = 1.0 + cols[6].scale_x = 1.0 + cols[8].scale_x = 1.33 flow_direction_icon = FLOW_DIRECTION_TO_ICON[element.FlowDirection or "NOTDEFINED"] connected_port = tool.System.get_connected_port(element) @@ -299,7 +300,7 @@ class BIM_PT_port(Panel): op = cols[1].operator("bim.cycle_flow_direction", text="", icon=flow_direction_icon, emboss=True) op.port_id = element.id() else: - cols[0].label(text="", icon="BLANK1") + cols[0].operator("bim.connect_port", icon="PLUGIN", text="") op = cols[1].operator("bim.cycle_flow_direction", text="", icon=flow_direction_icon, emboss=True) op.port_id = element.id() diff --git a/src/bonsai/bonsai/core/system.py b/src/bonsai/bonsai/core/system.py index 7a1df28280..87a43307b7 100644 --- a/src/bonsai/bonsai/core/system.py +++ b/src/bonsai/bonsai/core/system.py @@ -132,8 +132,8 @@ def remove_port(ifc: type[tool.Ifc], system: type[tool.System], port: ifcopenshe ifc.run("root.remove_product", product=port) -def connect_port(ifc: type[tool.Ifc], port1: ifcopenshell.entity_instance, port2: ifcopenshell.entity_instance) -> None: - ifc.run("system.connect_port", port1=port1, port2=port2) +def connect_port(ifc: type[tool.Ifc], port1: ifcopenshell.entity_instance, port2: ifcopenshell.entity_instance, direction: str = "NOTDEFINED") -> None: + ifc.run("system.connect_port", port1=port1, port2=port2, direction=direction) def disconnect_port(ifc: type[tool.Ifc], port: ifcopenshell.entity_instance) -> None: diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py b/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py index 8ffbfcf380..c53e4ae160 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py @@ -66,8 +66,6 @@ def disconnect_port(file: ifcopenshell.file, port: ifcopenshell.entity_instance) rels += port.ConnectedFrom or () for rel in rels: - rel.RelatingPort.FlowDirection = None - rel.RelatedPort.FlowDirection = None history = rel.OwnerHistory file.remove(rel) if history: From 791a41d6496a8a64ce2585577beb4e5d2c5386bd Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 13 Jan 2026 08:49:57 +0100 Subject: [PATCH 10/37] cleanup and black . --- .../bonsai/bim/module/system/operator.py | 55 +++++++++---------- src/bonsai/bonsai/bim/module/system/prop.py | 13 +++-- src/bonsai/bonsai/bim/module/system/ui.py | 55 +++++++++---------- src/bonsai/bonsai/core/system.py | 7 ++- src/bonsai/bonsai/tool/system.py | 1 + .../ifcopenshell/api/project/append_asset.py | 3 +- 6 files changed, 66 insertions(+), 68 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index 049307b32f..066e466bd0 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -186,7 +186,6 @@ class ShowPorts(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.show_ports" bl_label = "Show Ports" bl_options = {"REGISTER", "UNDO"} - element_id: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"}) @classmethod def poll(cls, context): @@ -199,12 +198,9 @@ class ShowPorts(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): # Ifc.Operator - as operator will sync object's position with IFC. - if self.element_id != 0: - element = tool.Ifc.get().by_id(self.element_id) - else: - element = tool.Ifc.get_entity(context.active_object) + element = tool.Ifc.get_entity(context.active_object) core.show_ports(tool.Ifc, tool.System, tool.Spatial, element=element) - + for port in tool.System.get_ports(element): connected_port = tool.System.get_connected_port(port) if connected_port: @@ -260,37 +256,39 @@ class ConnectPort(bpy.types.Operator, tool.Ifc.Operator): obj1 = context.active_object obj2 = context.selected_objects[0] if context.selected_objects[1] == obj1 else context.selected_objects[1] direction = tool.Ifc.get_entity(obj1).FlowDirection or "NOTDEFINED" - core.connect_port(tool.Ifc, port1=tool.Ifc.get_entity(obj1), port2=tool.Ifc.get_entity(obj2), direction=direction) + core.connect_port( + tool.Ifc, port1=tool.Ifc.get_entity(obj1), port2=tool.Ifc.get_entity(obj2), direction=direction + ) class AddRelatedPortConnection(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_related_port_connection" bl_label = "Connect Port" bl_options = {"REGISTER", "UNDO"} - + relating_port_id: bpy.props.IntProperty() - + def invoke(self, context, event): return context.window_manager.invoke_props_dialog(self) - + def draw(self, context): props = tool.System.get_system_props() self.layout.prop(props, "related_port", text="Select Port") - + def _execute(self, context): props = tool.System.get_system_props() - + if not props.related_port or props.related_port == "NONE": return {"CANCELLED"} - + port_obj = bpy.data.objects.get(props.related_port) related_port = tool.Ifc.get_entity(port_obj) relating_port = tool.Ifc.get().by_id(self.relating_port_id) - + direction = relating_port.FlowDirection or "NOTDEFINED" core.connect_port(tool.Ifc, port1=relating_port, port2=related_port, direction=direction) PortData.is_loaded = False - + return {"FINISHED"} @@ -436,13 +434,10 @@ class CycleFlowDirection(bpy.types.Operator, tool.Ifc.Operator): @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 + 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" return "Cycle through flow directions: SOURCE → SINK → SOURCEANDSINK → NOTDEFINED → SOURCE..." def _execute(self, context): @@ -451,30 +446,32 @@ class CycleFlowDirection(bpy.types.Operator, tool.Ifc.Operator): return {"CANCELLED"} current_direction = port.FlowDirection or "NOTDEFINED" - + flow_cycle_map = { "SOURCE": "SINK", "SINK": "SOURCEANDSINK", "SOURCEANDSINK": "NOTDEFINED", - "NOTDEFINED": "SOURCE" + "NOTDEFINED": "SOURCE", } next_direction = flow_cycle_map.get(current_direction, "SOURCE") tool.Ifc.run("attribute.edit_attributes", product=port, attributes={"FlowDirection": next_direction}) - + connected_port = tool.System.get_connected_port(port) if connected_port: connected_direction_map = { "SOURCE": "SINK", "SINK": "SOURCE", "SOURCEANDSINK": "SOURCEANDSINK", - "NOTDEFINED": "NOTDEFINED" + "NOTDEFINED": "NOTDEFINED", } connected_direction = connected_direction_map.get(next_direction, "NOTDEFINED") - tool.Ifc.run("attribute.edit_attributes", product=connected_port, attributes={"FlowDirection": connected_direction}) - + tool.Ifc.run( + "attribute.edit_attributes", product=connected_port, attributes={"FlowDirection": connected_direction} + ) + PortData.is_loaded = False - + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/system/prop.py b/src/bonsai/bonsai/bim/module/system/prop.py index e4e56a79a0..8954ab67f2 100644 --- a/src/bonsai/bonsai/bim/module/system/prop.py +++ b/src/bonsai/bonsai/bim/module/system/prop.py @@ -19,7 +19,6 @@ import bpy import bonsai.bim.handler import bonsai.tool as tool -import bonsai.core.system as core from bonsai.bim.module.system.data import SystemData import bonsai.bim.module.system.decorator as decorator from bonsai.bim.prop import StrProperty, Attribute @@ -93,23 +92,25 @@ def toggle_decorations(self: "BIMSystemProperties", context: bpy.types.Context) decorator.SystemDecorator.uninstall() -def get_available_ports_for_connection(self: "BIMSystemProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: +def get_available_ports_for_connection( + self: "BIMSystemProperties", context: bpy.types.Context +) -> list[tuple[str, str, str]]: items = [] active_object_ports = set(tool.System.get_ports(tool.Ifc.get_entity(context.active_object))) - + ifc_file = tool.Ifc.get() for ifc_port in ifc_file.by_type("IfcDistributionPort"): port = tool.Ifc.get_object(ifc_port) if not port: continue - + if tool.System.get_connected_port(ifc_port) is not None or ifc_port in active_object_ports: continue - + port_object = tool.Ifc.get_object(tool.System.get_port_relating_element(ifc_port)) suggestion = f"{port_object.name} > {port.name}" items.append((port.name, suggestion, "")) - + return items if items else [("NONE", "Ports are hidden or not available", "")] diff --git a/src/bonsai/bonsai/bim/module/system/ui.py b/src/bonsai/bonsai/bim/module/system/ui.py index 5e904a3206..e41295f40e 100644 --- a/src/bonsai/bonsai/bim/module/system/ui.py +++ b/src/bonsai/bonsai/bim/module/system/ui.py @@ -162,11 +162,9 @@ class BIM_PT_ports(Panel): if total_ports == 0: return - props = tool.System.get_system_props() - row = self.layout.row(align=True) - row.label(text="Ports located on object and connected Port/Objects:") - + row.label(text=f"Ports located in: {context.active_object.name} and connected Port/Objects:") + row = self.layout.row(align=True) cols = [row.column(align=True) for i in range(9)] cols[3].scale_x = 1.0 @@ -175,18 +173,17 @@ class BIM_PT_ports(Panel): for port_data in PortData.data["located_ports_data"]: flow_direction_icon = FLOW_DIRECTION_TO_ICON[port_data["FlowDirection"] or "NOTDEFINED"] - + 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: - op = cols[0].operator("bim.add_related_port_connection", text="", icon='PLUGIN') + op = cols[0].operator("bim.add_related_port_connection", text="", icon="PLUGIN") op.relating_port_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"] - - # 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"]) @@ -196,16 +193,20 @@ class BIM_PT_ports(Panel): if port_data["connected_obj_name"]: connected_obj = bpy.data.objects[port_data["connected_obj_name"]] - + port = tool.Ifc.get().by_id(port_data["id"]) connected_port = tool.System.get_connected_port(port) if connected_port: connected_port_obj = tool.Ifc.get_object(connected_port) if connected_port_obj: 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 = 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() + cols[5].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ( + connected_port.id() + ) cols[6].label(text=connected_port_obj.name) else: blank4 = cols[4].column(align=True) @@ -223,7 +224,7 @@ class BIM_PT_ports(Panel): blank5.scale_x = 0.1 blank5.label(text="", icon="BLANK1") cols[6].label(text="") - + 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=port_data["connected_obj_name"]) @@ -234,9 +235,9 @@ class BIM_PT_ports(Panel): blank5 = cols[5].column(align=True) blank5.scale_x = 0.1 blank5.label(text="", icon="BLANK1") - + cols[6].label(text="Port is disconnected") - + blank7 = cols[7].column(align=True) blank7.scale_x = 0.1 blank7.label(text="", icon="BLANK1") @@ -265,17 +266,8 @@ class BIM_PT_port(Panel): return True def draw(self, context): - self.props = tool.System.get_system_props() - layout = self.layout row = layout.row(align=True) - - 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.remove_port", icon="X", text="") if not PortData.is_loaded: PortData.load() @@ -283,18 +275,21 @@ class BIM_PT_port(Panel): if not PortData.data["is_port"]: return + relating_object_name = PortData.data["port_relating_object_name"] + row.label(text=f"IfcDistributionPort located in: {relating_object_name}") + row.operator("bim.remove_port", icon="X", text="") + element = tool.Ifc.get_entity(context.active_object) - props = tool.System.get_system_props() - + row = layout.row(align=True) cols = [row.column(align=True) for i in range(9)] cols[3].scale_x = 1.0 cols[6].scale_x = 1.0 cols[8].scale_x = 1.33 - + 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) @@ -303,10 +298,10 @@ class BIM_PT_port(Panel): cols[0].operator("bim.connect_port", icon="PLUGIN", text="") 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) @@ -314,7 +309,7 @@ class BIM_PT_port(Panel): 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] diff --git a/src/bonsai/bonsai/core/system.py b/src/bonsai/bonsai/core/system.py index 87a43307b7..01f1b7166e 100644 --- a/src/bonsai/bonsai/core/system.py +++ b/src/bonsai/bonsai/core/system.py @@ -132,7 +132,12 @@ def remove_port(ifc: type[tool.Ifc], system: type[tool.System], port: ifcopenshe ifc.run("root.remove_product", product=port) -def connect_port(ifc: type[tool.Ifc], port1: ifcopenshell.entity_instance, port2: ifcopenshell.entity_instance, direction: str = "NOTDEFINED") -> None: +def connect_port( + ifc: type[tool.Ifc], + port1: ifcopenshell.entity_instance, + port2: ifcopenshell.entity_instance, + direction: str = "NOTDEFINED", +) -> None: ifc.run("system.connect_port", port1=port1, port2=port2, direction=direction) diff --git a/src/bonsai/bonsai/tool/system.py b/src/bonsai/bonsai/tool/system.py index 0236b40fbd..3566dbd863 100644 --- a/src/bonsai/bonsai/tool/system.py +++ b/src/bonsai/bonsai/tool/system.py @@ -100,6 +100,7 @@ class System(bonsai.core.tool.System): @classmethod def create_empty_at_cursor_with_element_orientation(cls, element: ifcopenshell.entity_instance) -> bpy.types.Object: + # Is this necessary anymore? element_obj = tool.Ifc.get_object(element) obj = bpy.data.objects.new("Port", None) obj.matrix_world = element_obj.matrix_world diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py index 0b703b6064..0a5aee0e1c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py @@ -490,8 +490,7 @@ class Usecase: attribute_class = None if "." in attribute: attribute, attribute_class = attribute.split(".") - inverse_values = getattr(element, attribute, []) - for inverse in inverse_values: + for inverse in getattr(element, attribute, []): if attribute_class and inverse.is_a(attribute_class): self.add_inverse_element(inverse) elif not attribute_class: From 96a0e9f72d218c662193475eaa1bd221e10ec07f Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 13 Jan 2026 10:07:10 +0100 Subject: [PATCH 11/37] Adapt tests for create_port_at_cursor method --- src/bonsai/bonsai/core/tool.py | 1 + src/bonsai/test/core/test_system.py | 9 ++++----- src/bonsai/test/tool/test_system.py | 13 +++++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index d8e4dd2396..002f643e55 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -1074,6 +1074,7 @@ class Surveyor: @interface class System: def create_empty_at_cursor_with_element_orientation(cls, element): pass + def create_port_at_cursor(cls, system): pass def delete_element_objects(cls, elements): pass def disable_editing_system(cls): pass def disable_system_editing_ui(cls): pass diff --git a/src/bonsai/test/core/test_system.py b/src/bonsai/test/core/test_system.py index d907059b98..4612e14d69 100644 --- a/src/bonsai/test/core/test_system.py +++ b/src/bonsai/test/core/test_system.py @@ -153,11 +153,10 @@ class TestAddPort: def test_run(self, ifc, system): system.get_ports("element").should_be_called().will_return(["port"]) system.load_ports("element", ["port"]).should_be_called() - system.create_empty_at_cursor_with_element_orientation("element").should_be_called().will_return("obj") - system.run_root_assign_class( - obj="obj", ifc_class="IfcDistributionPort", should_add_representation=False - ).should_be_called().will_return("port") - ifc.run("system.assign_port", element="element", port="port").should_be_called() + #system.create_empty_at_cursor_with_element_orientation("element").should_be_called().will_return("obj") + #system.run_root_assign_class(obj="obj", ifc_class="IfcDistributionPort", should_add_representation=False).should_be_called().will_return("port") + system.create_port_at_cursor("element").should_be_called().will_return("port") + system.load_ports("element", ["port"]).should_be_called() subject.add_port(ifc, system, element="element") diff --git a/src/bonsai/test/tool/test_system.py b/src/bonsai/test/tool/test_system.py index de8e9026b0..ba20305140 100644 --- a/src/bonsai/test/tool/test_system.py +++ b/src/bonsai/test/tool/test_system.py @@ -103,6 +103,19 @@ class TestCreateEmptyAtCursorWithElementOrientation(NewFile): obj = subject.create_empty_at_cursor_with_element_orientation(element) assert obj.matrix_world == bpy.context.scene.cursor.matrix +class TestCreatePortAtCursor(NewFile): + def test_run(self): + assert bpy.context.scene + ifc = ifcopenshell.file() + tool.Ifc().set(ifc) + system = ifcopenshell.api.system.add_system(ifc) + element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDuctSegment") + ifcopenshell.api.system.assign_system(ifc, products=[element], system=system) + obj = tool.Ifc.link(element, bpy.data.objects.new("Object", None)) + port = subject.create_port_at_cursor(element) + assert port.is_a("IfcDistributionPort") + assert ifcopenshell.util.system.get_ports(element) == [port] + class TestDeleteElementObjects(NewFile): def test_run(self): From 2065d4ce78c46dbd52ba739e5bbfd326df026844 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 17 Jan 2026 17:55:11 +1100 Subject: [PATCH 12/37] Minor optimisation to prevent looping through all verts twice in decorator draw --- src/bonsai/bonsai/bim/module/model/polyline.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py index edd0b8981a..b389f2be1f 100644 --- a/src/bonsai/bonsai/bim/module/model/polyline.py +++ b/src/bonsai/bonsai/bim/module/model/polyline.py @@ -540,7 +540,7 @@ def get_generic_product_preview_data(context, relating_type): mouse_point = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) snap_obj = bpy.data.objects.get(snap_prop.snap_object) snap_element = tool.Ifc.get_entity(snap_obj) - rot_mat = Quaternion() + rot_mat = Matrix() if relating_type.is_a() in ["IfcDoorType", "IfcWindowType"] and snap_element and snap_element.is_a("IfcWall"): layers = tool.Model.get_material_layer_parameters(snap_element) axes = tool.Model.get_wall_axis(snap_obj, layers=layers) @@ -550,10 +550,10 @@ def get_generic_product_preview_data(context, relating_type): point_on_side_axis = tool.Cad.point_on_edge(mouse_point, axis_side) if (point_on_base_axis - mouse_point).length_squared <= (point_on_side_axis - mouse_point).length_squared: # mouse is snapped to the base axis, the preview looks exactly like the placed door / window - rot_mat = snap_obj.matrix_world.to_quaternion() + rot_mat = snap_obj.matrix_world else: # mouse is snapped to the side axis, the preview is inverted, rotate it now and correct x position later - rot_mat = snap_obj.matrix_world.to_quaternion() @ Quaternion(Vector((0, 0, 1)), radians(180)) + rot_mat = (snap_obj.matrix_world.to_quaternion() @ Quaternion(Vector((0, 0, 1)), radians(180))).to_matrix().to_4x4() mouse_point.z = snap_obj.matrix_world.translation.z @@ -586,9 +586,11 @@ def get_generic_product_preview_data(context, relating_type): if obj_type.data: data = ItemDecorator.get_obj_data(obj_type) - data["verts"] = [tuple(obj_type.matrix_world.inverted() @ Vector(v)) for v in data["verts"]] - data["verts"] = [tuple(rot_mat @ (Vector((v[0], v[1], (v[2] + rl)))) + mouse_point) for v in data["verts"]] - + obj_type_matrix_i = obj_type.matrix_world.inverted() + translate_mouse = Matrix.Translation(mouse_point) + translate_rl = Matrix.Translation((0.0, 0.0, rl)) + combined_m = translate_mouse @ rot_mat @ translate_rl @ obj_type_matrix_i + data["verts"] = [tuple(combined_m @ Vector(v)) for v in data["verts"]] return data From 16a90cadb55d63319d5e7d60a255e3a0e4ab0952 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 17 Jan 2026 17:57:07 +1100 Subject: [PATCH 13/37] Cap product decorator at 500 triangles to prevent unresponsive UI during dense product placement --- .../bonsai/bim/module/geometry/decorator.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/geometry/decorator.py b/src/bonsai/bonsai/bim/module/geometry/decorator.py index 14f48d8eec..0888dd66c4 100644 --- a/src/bonsai/bonsai/bim/module/geometry/decorator.py +++ b/src/bonsai/bonsai/bim/module/geometry/decorator.py @@ -74,9 +74,28 @@ class ItemDecorator: special_verts = [] special_edges = [] - if len(obj.data.loop_triangles) > 0: - verts = [tuple(obj.matrix_world @ v.co) for v in obj.data.vertices] - tris = [tuple(t.vertices) for t in obj.data.loop_triangles] + if (total_triangles := len(obj.data.loop_triangles)) > 0: + # TODO: this is a far too small threshold. + # This is just a stopgap optimisation until other slowdowns are solved. + if total_triangles > 500: + vert_map = {} + i = 0 + verts = [] + tris = [] + for tri in obj.data.loop_triangles[:500]: + new_tri = [] + for vert in tri.vertices: + if vert in vert_map: + new_tri.append(vert_map[vert]) + else: + vert_map[vert] = i + new_tri.append(i) + i += 1 + verts.append(tuple(obj.matrix_world @ obj.data.vertices[vert].co)) + tris.append(new_tri) + else: + verts = [tuple(obj.matrix_world @ v.co) for v in obj.data.vertices] + tris = [tuple(t.vertices) for t in obj.data.loop_triangles] i = len(verts) matrix_world = obj.matrix_world From f3295e266359b89fb94e1fa7b4c6945f65e739e3 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Sat, 17 Jan 2026 20:02:09 +0100 Subject: [PATCH 14/37] Refactor filter mode handling to simplify chained filters with set operations --- src/bonsai/bonsai/bim/helper.py | 47 ++++++++----------- .../bonsai/bim/module/search/operator.py | 5 +- src/bonsai/bonsai/bim/ui.py | 15 +----- src/bonsai/bonsai/tool/search.py | 25 ++-------- 4 files changed, 28 insertions(+), 64 deletions(-) diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index 1a92078be3..e81c483216 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -511,14 +511,15 @@ def draw_filter( row.operator("bim.edit_element_filter", icon="CHECKMARK", text="").filter_mode = "EXCLUDE" row.operator("bim.enable_editing_element_filter", icon="CANCEL", text="").filter_mode = "NONE" row = layout.row(align=True) - if not tool.Blender.get_addon_preferences().chain_filter_with_set_operations: + preferences = tool.Blender.get_addon_preferences() + if not preferences.chain_filter_with_set_operations: row.operator("bim.add_filter_group", text="Add Search Group", icon="ADD").module = module else: - if not filter_groups or not any(fg.filters for fg in filter_groups): - op = row.operator("bim.add_filter", text="Add Filter", icon="ADD") - op.type = "entity" - op.index = 0 - op.module = module + row.prop(sprops, "facet", text="") + op = row.operator("bim.add_filter", text="Add Filter", icon="ADD") + op.type = sprops.facet + op.index = 0 + op.module = module op = row.operator("bim.edit_filter_query", text="", icon="FILTER") if "module" in op.bl_rna.properties: op.module = module @@ -526,26 +527,23 @@ def draw_filter( for i, filter_group in enumerate(filter_groups): box = layout.box() - row = box.row(align=True) - row.prop(sprops, "facet", text="") - op = row.operator("bim.add_filter", text="Add Filter", icon="ADD") - op.type = sprops.facet - op.index = i - op.module = module - op = row.operator("bim.remove_filter_group", text="", icon="X") - op.index = i - op.module = module + preferences = tool.Blender.get_addon_preferences() + if not preferences.chain_filter_with_set_operations: + row = box.row(align=True) + row.prop(sprops, "facet", text="") + op = row.operator("bim.add_filter", text="Add Filter", icon="ADD") + op.type = sprops.facet + op.index = i + op.module = module + op = row.operator("bim.remove_filter_group", text="", icon="X") + op.index = i + op.module = module for j, ifc_filter in enumerate(filter_group.filters): if ifc_filter.type == "entity": row = box.row(align=True) preferences = tool.Blender.get_addon_preferences() - if preferences.chain_filter_with_set_operations: - show_mode_toggle = j > 0 - else: - show_mode_toggle = ( - preferences.default_filter_with_set_operations_for_globalid_and_class and j > 0 - ) # PR 7315 mode + show_mode_toggle = preferences.chain_filter_with_set_operations and j > 0 if show_mode_toggle: mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} op = row.operator( @@ -761,12 +759,7 @@ def draw_filter( elif ifc_filter.type == "instance": row = box.row(align=True) preferences = tool.Blender.get_addon_preferences() - if preferences.chain_filter_with_set_operations: - show_mode_toggle = j > 0 - else: - show_mode_toggle = ( - preferences.default_filter_with_set_operations_for_globalid_and_class and j > 0 - ) # PR 7315 mode + show_mode_toggle = preferences.chain_filter_with_set_operations and j > 0 if show_mode_toggle: mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} op = row.operator( diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index 2c7a506e78..62dd7efa36 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -765,10 +765,7 @@ class Search(Operator): preferences = tool.Blender.get_addon_preferences() # Migrate old ! prefix filters to new filter_mode system when preferences are enabled - if ( - preferences.chain_filter_with_set_operations - or preferences.default_filter_with_set_operations_for_globalid_and_class - ): + if preferences.chain_filter_with_set_operations: for filter_group in props.filter_groups: for ifc_filter in filter_group.filters: if ifc_filter.type not in ["entity", "instance"]: diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index c550c00872..49c2cf51b6 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -724,15 +724,10 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): ) chain_filter_with_set_operations: BoolProperty( - name="NEW filter mode: Enable chained filters with set operations", + name="NEW Filter mode: Enable chained filters with set operations", description="Enable chaining search filters with set operations: ADD (union: combine sets), SUBTRACT (difference: remove from set), FILTER (intersection: only elements in both sets), with autocomplete suggestions for filter values", default=False, ) - default_filter_with_set_operations_for_globalid_and_class: BoolProperty( - name="DEFAULT filter mode: Enable set operations for GlobalId/Class", - description="Enable ADD/SUBTRACT/FILTER toggle buttons on entity (Class) and instance (GlobalId) filters for the DEFAULT filter mode", - default=False, - ) save_metadata_blend_file: BoolProperty( name="Save non ifc data to metadata blend File", @@ -783,7 +778,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): container_hide_show_isolate: bool mass_time_units_in_wizard: bool chain_filter_with_set_operations: bool - default_filter_with_set_operations_for_globalid_and_class: bool save_metadata_blend_file: bool def draw(self, context: bpy.types.Context) -> None: @@ -979,14 +973,9 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "container_hide_show_isolate") layout.prop(self, "mass_time_units_in_wizard") - layout.label(text="Filtering modes:") - box = layout.box() - row = box.row(align=True) + row = layout.row(align=True) row.prop(self, "chain_filter_with_set_operations") row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/3270" - row = box.row(align=True) - row.prop(self, "default_filter_with_set_operations_for_globalid_and_class") - row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/comment/27030" layout.prop(self, "save_metadata_blend_file") if self.save_metadata_blend_file: row = layout.row() diff --git a/src/bonsai/bonsai/tool/search.py b/src/bonsai/bonsai/tool/search.py index a0e2e17456..439e3469b3 100644 --- a/src/bonsai/bonsai/tool/search.py +++ b/src/bonsai/bonsai/tool/search.py @@ -132,10 +132,7 @@ class Search(bonsai.core.tool.Search): else: value = ifc_filter.value - if ( - preferences.chain_filter_with_set_operations - or preferences.default_filter_with_set_operations_for_globalid_and_class - ): + if preferences.chain_filter_with_set_operations: value = value.lstrip("!") if ifc_filter.filter_mode == "SUBTRACT": value = f"!{value}" @@ -144,10 +141,7 @@ class Search(bonsai.core.tool.Search): elif ifc_filter.type == "entity": value = ifc_filter.value - if ( - preferences.chain_filter_with_set_operations - or preferences.default_filter_with_set_operations_for_globalid_and_class - ): + if preferences.chain_filter_with_set_operations: value = value.lstrip("!") if ifc_filter.filter_mode == "SUBTRACT": value = f"!{value}" @@ -215,19 +209,10 @@ class Search(bonsai.core.tool.Search): if filter_index == 0: mode = "ADD" else: - if ifc_filter.type in ["entity", "instance"]: - if ( - preferences.chain_filter_with_set_operations - or preferences.default_filter_with_set_operations_for_globalid_and_class - ): - mode = ifc_filter.filter_mode - else: - mode = "FILTER" if group_results else "ADD" + if preferences.chain_filter_with_set_operations: + mode = ifc_filter.filter_mode else: - if preferences.chain_filter_with_set_operations: - mode = ifc_filter.filter_mode - else: - mode = "FILTER" if group_results else "ADD" + mode = "FILTER" if group_results else "ADD" if mode == "ADD": results = ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), query) From 09d193045af3881175aee0643d4b7a9d50c939e4 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Sun, 18 Jan 2026 00:05:07 +0100 Subject: [PATCH 15/37] Enhance filter value suggestions to support enumerated properties in search --- .../bonsai/bim/module/search/operator.py | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index 2c7a506e78..52992fc8a5 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -445,20 +445,17 @@ class FilterValueSuggestions(Operator): if pset.HasProperties: for prop in pset.HasProperties: if hasattr(prop, "Name") and prop.Name == property_name: - if hasattr(prop, "NominalValue") and prop.NominalValue: - try: - value = prop.NominalValue.wrappedValue - if value is not None and value != "": - if not hasattr(value, "is_a") and not isinstance( - value, (tuple, list) - ): - str_value = str(value) - if not str_value.startswith("#") and not str_value.startswith( - "(" - ): - property_values.add(str_value) - except: - continue + if prop.is_a("IfcPropertyEnumeratedValue"): + if hasattr(prop, "EnumerationReference") and prop.EnumerationReference: + enum_reference = prop.EnumerationReference + if hasattr(enum_reference, "EnumerationValues"): + for enum_value in enum_reference.EnumerationValues: + property_values.add(str(enum_value.wrappedValue)) + if hasattr(prop, "EnumerationValues") and prop.EnumerationValues: + for enum_value in prop.EnumerationValues: + property_values.add(str(enum_value.wrappedValue)) + elif hasattr(prop, "NominalValue") and prop.NominalValue: + property_values.add(str(prop.NominalValue.wrappedValue)) except: continue return property_values From 9e2b7ba7c81c6e1e7740f959de2c3dafcf4bc56c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 04:41:14 +0000 Subject: [PATCH 16/37] Bump tar from 7.4.3 to 7.5.3 in /src/ifctester/webapp Bumps [tar](https://github.com/isaacs/node-tar) from 7.4.3 to 7.5.3. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v7.4.3...v7.5.3) --- updated-dependencies: - dependency-name: tar dependency-version: 7.5.3 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- src/ifctester/webapp/package-lock.json | 33 +++++++------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/src/ifctester/webapp/package-lock.json b/src/ifctester/webapp/package-lock.json index 392c2dfe13..9b7f5b6058 100644 --- a/src/ifctester/webapp/package-lock.json +++ b/src/ifctester/webapp/package-lock.json @@ -1955,9 +1955,9 @@ } }, "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, "license": "MIT", "dependencies": { @@ -1967,22 +1967,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mode-watcher": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz", @@ -2867,17 +2851,16 @@ } }, "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz", + "integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { From 4c1b3221ce1416a23c048332bd47a706d53a58a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 04:41:16 +0000 Subject: [PATCH 17/37] Bump gersemi from 0.24 to 0.25.1 Bumps [gersemi](https://github.com/BlankSpruce/gersemi) from 0.24 to 0.25.1. - [Release notes](https://github.com/BlankSpruce/gersemi/releases) - [Changelog](https://github.com/BlankSpruce/gersemi/blob/master/CHANGELOG.md) - [Commits](https://github.com/BlankSpruce/gersemi/compare/0.24.0...0.25.1) --- updated-dependencies: - dependency-name: gersemi dependency-version: 0.25.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b3f08435e5..0c7914f541 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ dependencies = [ "black==25.12", "ruff==0.14.13", "poethepoet", - "gersemi==0.24", + "gersemi==0.25.1", ] [tool.black] From e55955b72a6f7d33ca7cbd7ca3deea4f474de8cd Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 21 Jan 2026 18:11:20 +1100 Subject: [PATCH 18/37] See #7566. Fix unusable lag due to dense meshes with product preview Previously, when the add occurrence modal operator was executed, on every modal loop (i.e. every mouse movement) it would fetch the mesh geometry to be previewed, store the verts / edges / faces in mesh collections, then the decorator would fetch that geometry, the clear the collections, in a loop. I've removed the Blender collections. Instead the same strategy is used as in ItemDecorator i.e. the mesh and verts are fetched once during decorator installation, then on each draw call only a single vertex loop to multiply by the transformation matrix for snapping and mouse position. You can test with the LOD400 model in #7566. On my machine it would cause lag on anything with >500 faces. Now it seems to work without lag on a 26k polygon mesh. --- .../bonsai/bim/module/geometry/decorator.py | 23 +- .../bonsai/bim/module/model/__init__.py | 3 - .../bonsai/bim/module/model/decorator.py | 593 +++++++++++++++++- .../bonsai/bim/module/model/polyline.py | 586 +---------------- src/bonsai/bonsai/bim/module/model/product.py | 2 - src/bonsai/bonsai/bim/module/model/profile.py | 3 - src/bonsai/bonsai/bim/module/model/prop.py | 11 - src/bonsai/bonsai/bim/module/model/slab.py | 3 - src/bonsai/bonsai/bim/module/model/wall.py | 3 - src/bonsai/bonsai/tool/model.py | 23 +- 10 files changed, 594 insertions(+), 656 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/geometry/decorator.py b/src/bonsai/bonsai/bim/module/geometry/decorator.py index 0888dd66c4..02cb4bd255 100644 --- a/src/bonsai/bonsai/bim/module/geometry/decorator.py +++ b/src/bonsai/bonsai/bim/module/geometry/decorator.py @@ -75,27 +75,8 @@ class ItemDecorator: special_edges = [] if (total_triangles := len(obj.data.loop_triangles)) > 0: - # TODO: this is a far too small threshold. - # This is just a stopgap optimisation until other slowdowns are solved. - if total_triangles > 500: - vert_map = {} - i = 0 - verts = [] - tris = [] - for tri in obj.data.loop_triangles[:500]: - new_tri = [] - for vert in tri.vertices: - if vert in vert_map: - new_tri.append(vert_map[vert]) - else: - vert_map[vert] = i - new_tri.append(i) - i += 1 - verts.append(tuple(obj.matrix_world @ obj.data.vertices[vert].co)) - tris.append(new_tri) - else: - verts = [tuple(obj.matrix_world @ v.co) for v in obj.data.vertices] - tris = [tuple(t.vertices) for t in obj.data.loop_triangles] + verts = [tuple(obj.matrix_world @ v.co) for v in obj.data.vertices] + tris = [tuple(t.vertices) for t in obj.data.loop_triangles] i = len(verts) matrix_world = obj.matrix_world diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 47ef5a0343..c758aa7f54 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -140,7 +140,6 @@ classes = ( prop.BIMRailingProperties, prop.BIMRoofProperties, prop.BIMPolylineProperties, - prop.BIMProductPreviewProperties, prop.BIMExternalParametricGeometryProperties, ui.BIM_PT_array, ui.BIM_PT_stair, @@ -263,7 +262,6 @@ def register(): bpy.types.Scene.BIMModelProperties = bpy.props.PointerProperty(type=prop.BIMModelProperties) bpy.types.Scene.BIMPolylineProperties = bpy.props.PointerProperty(type=prop.BIMPolylineProperties) - bpy.types.Scene.BIMProductPreviewProperties = bpy.props.PointerProperty(type=prop.BIMProductPreviewProperties) bpy.types.Object.BIMArrayProperties = bpy.props.PointerProperty(type=prop.BIMArrayProperties) bpy.types.Object.BIMStairProperties = bpy.props.PointerProperty(type=prop.BIMStairProperties) bpy.types.Object.BIMSverchokProperties = bpy.props.PointerProperty(type=prop.BIMSverchokProperties) @@ -288,7 +286,6 @@ def unregister(): del bpy.types.Scene.BIMModelProperties del bpy.types.Scene.BIMPolylineProperties - del bpy.types.Scene.BIMProductPreviewProperties del bpy.types.Object.BIMArrayProperties del bpy.types.Object.BIMStairProperties del bpy.types.Object.BIMSverchokProperties diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index aad4c647ee..43d770828e 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -26,10 +26,10 @@ import ifcopenshell import bonsai.tool as tool import math import mathutils -from math import sin, cos, radians +from math import sin, cos, tan, radians from bpy.types import SpaceView3D from bpy_extras import view3d_utils -from mathutils import Vector, Matrix +from mathutils import Vector, Matrix, Quaternion from gpu_extras.batch import batch_for_shader from gpu_extras.presets import draw_circle_2d from typing import Union @@ -879,13 +879,43 @@ class PolylineDecorator: class ProductDecorator: is_installed = False handlers = [] + preview_mode: Literal["PROFILE_VERTICAL", "PROFILE_HORIZONTAL", "LAYER2", "LAYER3", "GENERIC"] relating_type = None + obj_data: dict[str, list] = {} + obj_matrix_i = None @classmethod def install(cls, context): + from bonsai.bim.module.geometry.decorator import ItemDecorator + if cls.is_installed: cls.uninstall() + + props = tool.Model.get_model_props() + handler = cls() + if ( + (props.relating_type_id) + and (relating_type := tool.Ifc.get().by_id(int(props.relating_type_id))) + and (relating_type_obj := tool.Ifc.get_object(relating_type)) + ): + handler.relating_type = relating_type + if tool.Model.get_usage_type(relating_type) == "PROFILE": + if relating_type.is_a() in {"IfcColumnType", "IfcPileType"}: + handler.preview_mode = "PROFILE_VERTICAL" + else: + handler.preview_mode = "PROFILE_HORIZONTAL" + elif tool.Model.get_usage_type(relating_type) == "LAYER2": + handler.preview_mode = "LAYER2" + elif tool.Model.get_usage_type(relating_type) == "LAYER3": + handler.preview_mode = "LAYER3" + else: + handler.preview_mode = "GENERIC" + if relating_type_obj.data: + handler.obj_data = ItemDecorator.get_obj_data(relating_type_obj) + handler.obj_data["raw_verts"] = [Vector(v) for v in handler.obj_data["verts"]] + handler.obj_matrix_i = relating_type_obj.matrix_world.inverted() + cls.handlers.append( SpaceView3D.draw_handler_add(handler.draw_product_preview, (context,), "WINDOW", "POST_VIEW") ) @@ -893,10 +923,6 @@ class ProductDecorator: @classmethod def uninstall(cls): - props = tool.Model.get_product_preview_props() # updated by model/polyline.py - props.verts.clear() - props.edges.clear() - props.tris.clear() for handler in cls.handlers: try: SpaceView3D.draw_handler_remove(handler, "WINDOW") @@ -912,14 +938,6 @@ class ProductDecorator: shader.uniform_float("color", color) batch.draw(shader) - def get_product_preview_data(self, context) -> dict[str, Any]: - props = tool.Model.get_product_preview_props() - data: dict[str, Any] = {} - data["verts"] = [(*v.value_3d,) for v in props.verts] - data["edges"] = [(int(e.value_2d[0]), int(e.value_2d[1])) for e in props.edges] - data["tris"] = [(int(t.value_3d[0]), int(t.value_3d[1]), int(t.value_3d[2])) for t in props.tris] - return data - def draw_product_preview(self, context): def transparent_color(color, alpha=0.1): color = [i for i in color] @@ -945,12 +963,549 @@ class ProductDecorator: else: return - product_preview_data = self.get_product_preview_data(context) - if product_preview_data: - self.draw_batch("LINES", product_preview_data["verts"], decorator_color, product_preview_data["edges"]) - self.draw_batch( - "TRIS", product_preview_data["verts"], transparent_color(decorator_color), product_preview_data["tris"] + if self.preview_mode == "LAYER2": + data = self.get_wall_preview_data() + elif self.preview_mode == "LAYER3": + data = self.get_slab_preview_data() + elif self.preview_mode == "PROFILE_VERTICAL": + data = self.get_vertical_profile_preview_data() + elif self.preview_mode == "PROFILE_HORIZONTAL": + data = self.get_horizontal_profile_preview_data() + elif self.preview_mode == "GENERIC": + data = self.get_generic_preview_data() + if data: + self.draw_batch("LINES", data["verts"], decorator_color, data["edges"]) + self.draw_batch("TRIS", data["verts"], transparent_color(decorator_color), data["tris"]) + + def get_wall_preview_data(self): + relating_type = self.relating_type + # Get properties from object type + model_props = tool.Model.get_model_props() + direction_sense = model_props.direction_sense + direction = 1 + if direction_sense == "NEGATIVE": + direction = -1 + + layers = tool.Model.get_material_layer_parameters(relating_type) + if not layers["thickness"]: + return + thickness = layers["thickness"] + thickness *= direction + + offset_type = model_props.offset_type_vertical + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + offset = model_props.offset * unit_scale + + height = float(model_props.extrusion_depth) + rl = float(model_props.rl1) + x_angle = float(model_props.x_angle) + if x_angle > radians(90) or x_angle < radians(-90): + height *= -1 + angle_distance = height * tan(x_angle) + thickness *= 1 / cos(x_angle) + + data = {} + data["verts"] = [] + + # Verts + polyline_vertices = [] + polyline_props = tool.Model.get_polyline_props() + polyline_data = polyline_props.insertion_polyline + polyline_points = polyline_data[0].polyline_points if polyline_data else [] + if len(polyline_points) < 2: + data = [] + return + for point in polyline_points: + polyline_vertices.append(Vector((point.x, point.y, point.z))) + + is_closed = False + if ( + polyline_vertices[0].x == polyline_vertices[-1].x + and polyline_vertices[0].y == polyline_vertices[-1].y + and polyline_vertices[0].z == polyline_vertices[-1].z + ): + is_closed = True + polyline_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed. + + bm_base = tool.Model.create_bmesh_from_vertices(polyline_vertices, is_closed) + base_vertices = tool.Cad.offset_edges(bm_base, offset) + offset_base_verts = tool.Cad.offset_edges(bm_base, thickness + offset) + top_vertices = tool.Cad.offset_edges(bm_base, angle_distance + offset) + offset_top_verts = tool.Cad.offset_edges(bm_base, angle_distance + thickness + offset) + if is_closed: + base_vertices.append(base_vertices[0]) + offset_base_verts.append(offset_base_verts[0]) + top_vertices.append(top_vertices[0]) + offset_top_verts.append(offset_top_verts[0]) + + if offset_base_verts is not None: + for v in base_vertices: + data["verts"].append((v.co.x, v.co.y, v.co.z + rl)) + + for v in offset_base_verts[::-1]: + data["verts"].append((v.co.x, v.co.y, v.co.z + rl)) + + for v in top_vertices: + data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height)) + + for v in offset_top_verts[::-1]: + data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height)) + + bm_base.free() + + # Edges and Tris + points = [] + side_edges_1 = [] + side_edges_2 = [] + base_edges = [] + + for i in range(len(data["verts"])): + points.append(Vector(data["verts"][i])) + + n = len(points) // 2 + bottom_side_1 = [[i, (i + 1) % (n)] for i in range((n - 1) // 2)] + bottom_side_2 = [[i, (i + 1) % (n)] for i in range(n // 2, n - 1)] + bottom_connections = [[i, n - i - 1] for i in range(n // 2)] + bottom_loop = bottom_connections + bottom_side_1 + bottom_side_2 + side_edges_1.extend(bottom_side_1) + side_edges_2.extend(bottom_side_2) + base_edges.extend(bottom_loop) + + upper_side_1 = [[i + n for i in edges] for edges in bottom_side_1] + upper_side_2 = [[i + n for i in edges] for edges in bottom_side_2] + upper_loop = [[i + n for i in edges] for edges in bottom_loop] + side_edges_1.extend(upper_side_1) + side_edges_2.extend(upper_side_2) + base_edges.extend(upper_loop) + + loops = [side_edges_1, side_edges_2, base_edges] + + data["edges"] = [] + data["tris"] = [] + for i, group in enumerate(loops): + bm = bmesh.new() + + new_verts = [bm.verts.new(v) for v in points] + new_edges = [bm.edges.new((new_verts[e[0]], new_verts[e[1]])) for e in group] + + bm.verts.index_update() + bm.edges.index_update() + + if i == 2: + new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) + new_faces = bmesh.ops.bridge_loops(bm, edges=bm.edges, use_pairs=True, use_cyclic=True) + + bm.verts.index_update() + bm.edges.index_update() + edges = [[v.index for v in e.verts] for e in bm.edges] + tris = [[l.vert.index for l in loop] for loop in bm.calc_loop_triangles()] + data["edges"].extend(edges) + data["tris"].extend(tris) + + data["edges"] = list(set(tuple(e) for e in data["edges"])) + data["tris"] = list(set(tuple(t) for t in data["tris"])) + return data + + def get_slab_preview_data(self): + relating_type = self.relating_type + model_props = tool.Model.get_model_props() + x_angle = 0 if tool.Cad.is_x(model_props.x_angle, 0, tolerance=0.001) else model_props.x_angle + direction_sense = model_props.direction_sense + direction = 1 + if direction_sense == "NEGATIVE": + direction = -1 + + layers = tool.Model.get_material_layer_parameters(relating_type) + if not layers["thickness"]: + return + thickness = layers["thickness"] * abs(1 / cos(x_angle)) + thickness *= direction + + offset_type = model_props.offset_type_horizontal + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + offset = model_props.offset * abs(1 / cos(x_angle)) * unit_scale + + data = {} + data["verts"] = [] + # Verts + polyline_vertices = [] + polyline_props = tool.Model.get_polyline_props() + polyline_data = polyline_props.insertion_polyline + polyline_points = polyline_data[0].polyline_points if polyline_data else [] + if len(polyline_points) < 3: + data = [] + return + for point in polyline_points: + polyline_vertices.append(Vector((point.x, point.y, point.z))) + if x_angle: + # Get vertices relative to the first polyline point as origin + local_vertices = [v - Vector(polyline_vertices[0]) for v in polyline_vertices] + # Make the transformation relative to the x_angle + transformed_vertices = [Vector((v.x, v.y * (1 / cos(x_angle)), v.z)) for v in local_vertices] + # Convert back to world origin + polyline_vertices = [v + Vector(polyline_vertices[0]) for v in transformed_vertices] + if offset != 0: + polyline_vertices = [v + Vector((0, 0, offset)) for v in polyline_vertices] + is_closed = True + if ( + polyline_vertices[0].x == polyline_vertices[-1].x + and polyline_vertices[0].y == polyline_vertices[-1].y + and polyline_vertices[0].z == polyline_vertices[-1].z + ): + polyline_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed. + bm = tool.Model.create_bmesh_from_vertices(polyline_vertices, is_closed) + bm.verts.ensure_lookup_table() + if x_angle: + rot_mat = Matrix.Rotation(x_angle, 3, "X") + if abs(x_angle) > (pi / 2): + rot_mat = rot_mat @ Matrix.Scale(-1, 3, (0, 1, 0)) + bmesh.ops.rotate(bm, cent=Vector(bm.verts[0].co), verts=bm.verts, matrix=rot_mat) + new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) + new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.edges[:] + bm.faces[:]) + new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)] + new_faces = bmesh.ops.translate(bm, verts=new_verts, vec=(0.0, 0.0, thickness)) + bm.verts.index_update() + bm.edges.index_update() + verts = [tuple(v.co) for v in bm.verts] + edges = [[v.index for v in e.verts] for e in bm.edges] + tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] + data["verts"] = verts + data["edges"] = edges + data["tris"] = tris + return data + + def get_vertical_profile_preview_data(self) -> dict[str, Any]: + relating_type = self.relating_type + material = ifcopenshell.util.element.get_material(relating_type) + try: + profile = material.MaterialProfiles[0].Profile + except: + return {} + + model_props = tool.Model.get_model_props() + extrusion_depth = model_props.extrusion_depth + cardinal_point = model_props.cardinal_point + rot_mat = Quaternion() + if relating_type.is_a("IfcBeamType"): + y_rot = Quaternion((0.0, 1.0, 0.0), radians(90)) + z_rot = Quaternion((0.0, 0.0, 1.0), radians(90)) + rot_mat = y_rot @ z_rot + # Get profile data + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + shape = ifcopenshell.geom.create_shape(settings, profile) + + verts = shape.verts + if not verts: + raise RuntimeError(f"Profile shape has no vertices, it probably is invalid: '{profile}'.") + + edges = shape.edges + + grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)] + grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)] + + # Create offsets based on cardinal point + min_x = min(v[0] for v in grouped_verts) + max_x = max(v[0] for v in grouped_verts) + min_y = min(v[1] for v in grouped_verts) + max_y = max(v[1] for v in grouped_verts) + + x_offset = (max_x - min_x) / 2 + y_offset = (max_y - min_y) / 2 + + match cardinal_point: + case "1": + grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] + case "2": + grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts] + case "3": + grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] + case "4": + grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts] + case "5": + grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts] + case "6": + grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts] + case "7": + grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] + case "8": + grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts] + case "9": + grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] + + # Create extrusion bmesh + bm = bmesh.new() + + grouped_verts.append(grouped_verts[0]) # Close profile + new_verts = [bm.verts.new(v) for v in grouped_verts] + new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(grouped_verts) - 1)] + + bm.verts.index_update() + bm.edges.index_update() + + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.001) + + new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) + + new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.faces, use_dissolve_ortho_edges=True) + new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)] + new_faces = bmesh.ops.translate(bm, verts=new_verts, vec=(0.0, 0.0, extrusion_depth)) + + bm.verts.index_update() + bm.edges.index_update() + tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] + + # Calculate rotation, mouse position, angle and cardinal point + polyline_props = tool.Model.get_polyline_props() + snap_prop = polyline_props.snap_mouse_point[0] + mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + data = {} + + verts = [tuple(v.co) for v in bm.verts] + verts = [tuple(rot_mat @ Vector(v)) for v in verts] + verts = [tuple(Vector(v) + mouse_point) for v in verts] + min_z = min(v.co.z for v in bm.verts) + max_z = max(v.co.z for v in bm.verts) + # Add axis verts + verts.append(tuple(mouse_point)) + verts.append(tuple(mouse_point + Vector((0, 0, max_z)))) + # Add only profile edges + edges = [] + for edge in bm.edges: + if (edge.verts[0].co.z == min_z and edge.verts[1].co.z == min_z) or ( + edge.verts[0].co.z == max_z and edge.verts[1].co.z == max_z + ): + edges.append(edge) + # Add axis edge + edges = [(edge.verts[0].index, edge.verts[1].index) for edge in edges] + edges.append((len(verts) - 1, len(verts) - 2)) + data["verts"] = verts + data["edges"] = edges + data["tris"] = tris + + bm.free() + return data + + def get_horizontal_profile_preview_data(self) -> dict[str, Any]: + relating_type = self.relating_type + material = ifcopenshell.util.element.get_material(relating_type) + try: + profile_curve = material.MaterialProfiles[0].Profile + except: + return {} + + model_props = tool.Model.get_model_props() + cardinal_point = model_props.cardinal_point + + polyline_verts = [] + polyline_props = tool.Model.get_polyline_props() + polyline_data = polyline_props.insertion_polyline + polyline_points = polyline_data[0].polyline_points if polyline_data else [] + if len(polyline_points) < 2: + return {} + for point in polyline_points: + polyline_verts.append(Vector((point.x, point.y, point.z))) + polyline_edges = [(i, i + 1) for i in range(len(polyline_verts) - 1)] + + # Get profile shape + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + shape = ifcopenshell.geom.create_shape(settings, profile_curve) + + verts = shape.verts + if not verts: + raise RuntimeError(f"Profile shape has no vertices, it probably is invalid: '{profile_curve}'.") + + edges = shape.edges + + grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)] + grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)] + + # Create offsets based on cardinal point + min_x = min(v[0] for v in grouped_verts) + max_x = max(v[0] for v in grouped_verts) + min_y = min(v[1] for v in grouped_verts) + max_y = max(v[1] for v in grouped_verts) + + x_offset = (max_x - min_x) / 2 + y_offset = (max_y - min_y) / 2 + + match cardinal_point: + case "1": + grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] + case "2": + grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts] + case "3": + grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] + case "4": + grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts] + case "5": + grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts] + case "6": + grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts] + case "7": + grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] + case "8": + grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts] + case "9": + grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] + + data: dict[str, Any] = {} + data["verts"] = [] + data["edges"] = [] + data["tris"] = [] + + grouped_verts = [(v) for v in grouped_verts] + + all_bm = bmesh.new() + for i in range(len(polyline_verts) - 1): + mesh = bpy.data.meshes.new("TempMesh") + # Create the initial mesh from the profile verts + bm = tool.Model.create_bmesh_from_vertices(grouped_verts, is_closed=True) + bm.verts.ensure_lookup_table() + # Creates the clipping plane formed by two segments. + # The first one is for the profile start, based on the current and previous segment of the polyline. + # The second is for the profile end, based on the current and the next segment. + if i == 0: + d = (polyline_verts[i + 1] - polyline_verts[i]).normalized() + clip_start = d + else: + d1 = (polyline_verts[i] - polyline_verts[i - 1]).normalized() + d2 = (polyline_verts[i] - polyline_verts[i + 1]).normalized() + clip_start = (d1 - d2).normalized() + + if i == len(polyline_verts) - 2: + d = (polyline_verts[i + 1] - polyline_verts[i]).normalized() + clip_end = d + else: + d1 = (polyline_verts[i + 1] - polyline_verts[i]).normalized() + d2 = (polyline_verts[i + 1] - polyline_verts[i + 2]).normalized() + clip_end = (d1 - d2).normalized() + + # Rotates the profile face to the right direction + direction = polyline_verts[i + 1] - polyline_verts[i] + position = polyline_verts[i] + rotation_matrix = direction.to_track_quat("Z", "Y").to_matrix().to_4x4() + bmesh.ops.transform(bm, verts=bm.verts, matrix=rotation_matrix) + bmesh.ops.translate(bm, verts=bm.verts, vec=position) + bmesh.ops.translate(bm, verts=bm.verts, vec=-direction) + + # Extrude and move the new face + last_face = bmesh.ops.extrude_face_region(bm, geom=bm.edges[:] + bm.faces[:]) + new_verts = [e for e in last_face["geom"] if isinstance(e, bmesh.types.BMVert)] + bmesh.ops.translate(bm, verts=new_verts, vec=direction * 3) + # Apply the cutting planes + cut = bmesh.ops.bisect_plane( + bm, + geom=bm.verts[:] + bm.edges[:] + bm.faces[:], + plane_co=polyline_verts[i], + plane_no=clip_start, + clear_inner=True, ) + bm.verts.index_update() + bm.edges.index_update() + cut = bmesh.ops.bisect_plane( + bm, + geom=bm.verts[:] + bm.edges[:] + bm.faces[:], + plane_co=polyline_verts[i + 1], + plane_no=clip_end, + clear_outer=True, + ) + + bm.to_mesh(mesh) + bm.free() + mesh.update() + all_bm.from_mesh(mesh) + bpy.data.meshes.remove(bpy.data.meshes["TempMesh"]) + + # It's necessary to add the mesh to an object to get the expected result. + mesh = bpy.data.meshes.new("TempMesh2") + all_bm.to_mesh(mesh) + all_bm.free() + obj = bpy.data.objects.new("TempObj", mesh) + bm = bmesh.new() + bm.from_mesh(obj.data) + bpy.data.meshes.remove(bpy.data.meshes["TempMesh2"]) + + verts = [tuple(v.co) for v in bm.verts] + edges = [[v.index for v in e.verts] for e in bm.edges] + tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] + data["verts"] = verts + data["edges"] = edges + data["tris"] = tris + bm.free() + return data + + def get_generic_preview_data(self): + if not (data := self.obj_data): + return + relating_type = self.relating_type + model_props = tool.Model.get_model_props() + if relating_type.is_a("IfcDoorType"): + rl = float(model_props.rl1) + elif relating_type.is_a("IfcWindowType"): + rl = float(model_props.rl2) + else: + rl = 0 + polyline_props = tool.Model.get_polyline_props() + snap_prop = polyline_props.snap_mouse_point[0] + default_container_elevation = tool.Root.get_default_container_elevation() + mouse_point = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) + snap_obj = bpy.data.objects.get(snap_prop.snap_object) + snap_element = tool.Ifc.get_entity(snap_obj) + rot_mat = Matrix() + if relating_type.is_a() in ["IfcDoorType", "IfcWindowType"] and snap_element and snap_element.is_a("IfcWall"): + layers = tool.Model.get_material_layer_parameters(snap_element) + axes = tool.Model.get_wall_axis(snap_obj, layers=layers) + axis_base = axes["base"] + axis_side = axes["side"] + point_on_base_axis = tool.Cad.point_on_edge(mouse_point, axis_base) + point_on_side_axis = tool.Cad.point_on_edge(mouse_point, axis_side) + if (point_on_base_axis - mouse_point).length_squared <= (point_on_side_axis - mouse_point).length_squared: + # mouse is snapped to the base axis, the preview looks exactly like the placed door / window + rot_mat = snap_obj.matrix_world + else: + # mouse is snapped to the side axis, the preview is inverted, rotate it now and correct x position later + rot_mat = ( + (snap_obj.matrix_world.to_quaternion() @ Quaternion(Vector((0, 0, 1)), radians(180))) + .to_matrix() + .to_4x4() + ) + + mouse_point.z = snap_obj.matrix_world.translation.z + + if snap_element and (container := ifcopenshell.util.element.get_container(snap_element)): + container_obj = tool.Ifc.get_object(container) + mouse_point.z = container_obj.location.z + + obj_type = tool.Ifc.get_object(relating_type) + + subcontexts = tool.Drawing.get_active_drawing_subcontexts() + if not subcontexts: + subcontexts = [("Model", "Body", "MODEL_VIEW")] + + active_context = tool.Geometry.get_active_representation_context(obj_type) + active_context_params = tool.Geometry.get_subcontext_parameters(active_context) + for subcontext in subcontexts: + if subcontext == active_context_params: + break + + representation = ifcopenshell.util.representation.get_representation(relating_type, *subcontext) + if representation: + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj_type, + representation, + ) + context.view_layer.update() + break + + translate_mouse = Matrix.Translation(mouse_point) + translate_rl = Matrix.Translation((0.0, 0.0, rl)) + combined_m = translate_mouse @ rot_mat @ translate_rl @ self.obj_matrix_i + data["verts"] = [tuple(combined_m @ v) for v in data["raw_verts"]] + return data class WallAxisDecorator: diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py index b389f2be1f..d2514321f3 100644 --- a/src/bonsai/bonsai/bim/module/model/polyline.py +++ b/src/bonsai/bonsai/bim/module/model/polyline.py @@ -35,565 +35,12 @@ import bonsai.core.root import bonsai.core.geometry import bonsai.core.model as core import bonsai.tool as tool -from math import pi, sin, cos, degrees, tan, radians -from mathutils import Vector, Matrix, Quaternion -from bonsai.bim.module.model.opening import FilledOpeningGenerator +from mathutils import Vector from bonsai.bim.module.model.decorator import PolylineDecorator -from bonsai.bim.module.geometry.decorator import ItemDecorator from typing import Optional, Union, Literal, Any from lark import Lark, Transformer -def create_bmesh_from_vertices(vertices, is_closed=False): - bm = bmesh.new() - - new_verts = [bm.verts.new(v) for v in vertices] - if is_closed: - new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] - new_edges.append( - bm.edges.new((new_verts[-1], new_verts[0])) - ) # Add an edge between the last an first point to make it closed. - else: - new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] - - bm.verts.index_update() - bm.edges.index_update() - return bm - - -def get_wall_preview_data(context, relating_type): - # Get properties from object type - model_props = tool.Model.get_model_props() - direction_sense = model_props.direction_sense - direction = 1 - if direction_sense == "NEGATIVE": - direction = -1 - - layers = tool.Model.get_material_layer_parameters(relating_type) - if not layers["thickness"]: - return - thickness = layers["thickness"] - thickness *= direction - - offset_type = model_props.offset_type_vertical - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - offset = model_props.offset * unit_scale - - height = float(model_props.extrusion_depth) - rl = float(model_props.rl1) - x_angle = float(model_props.x_angle) - if x_angle > radians(90) or x_angle < radians(-90): - height *= -1 - angle_distance = height * tan(x_angle) - thickness *= 1 / cos(x_angle) - - data = {} - data["verts"] = [] - - # Verts - polyline_vertices = [] - polyline_props = tool.Model.get_polyline_props() - polyline_data = polyline_props.insertion_polyline - polyline_points = polyline_data[0].polyline_points if polyline_data else [] - if len(polyline_points) < 2: - data = [] - return - for point in polyline_points: - polyline_vertices.append(Vector((point.x, point.y, point.z))) - - is_closed = False - if ( - polyline_vertices[0].x == polyline_vertices[-1].x - and polyline_vertices[0].y == polyline_vertices[-1].y - and polyline_vertices[0].z == polyline_vertices[-1].z - ): - is_closed = True - polyline_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed. - - bm_base = create_bmesh_from_vertices(polyline_vertices, is_closed) - base_vertices = tool.Cad.offset_edges(bm_base, offset) - offset_base_verts = tool.Cad.offset_edges(bm_base, thickness + offset) - top_vertices = tool.Cad.offset_edges(bm_base, angle_distance + offset) - offset_top_verts = tool.Cad.offset_edges(bm_base, angle_distance + thickness + offset) - if is_closed: - base_vertices.append(base_vertices[0]) - offset_base_verts.append(offset_base_verts[0]) - top_vertices.append(top_vertices[0]) - offset_top_verts.append(offset_top_verts[0]) - - if offset_base_verts is not None: - for v in base_vertices: - data["verts"].append((v.co.x, v.co.y, v.co.z + rl)) - - for v in offset_base_verts[::-1]: - data["verts"].append((v.co.x, v.co.y, v.co.z + rl)) - - for v in top_vertices: - data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height)) - - for v in offset_top_verts[::-1]: - data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height)) - - bm_base.free() - - # Edges and Tris - points = [] - side_edges_1 = [] - side_edges_2 = [] - base_edges = [] - - for i in range(len(data["verts"])): - points.append(Vector(data["verts"][i])) - - n = len(points) // 2 - bottom_side_1 = [[i, (i + 1) % (n)] for i in range((n - 1) // 2)] - bottom_side_2 = [[i, (i + 1) % (n)] for i in range(n // 2, n - 1)] - bottom_connections = [[i, n - i - 1] for i in range(n // 2)] - bottom_loop = bottom_connections + bottom_side_1 + bottom_side_2 - side_edges_1.extend(bottom_side_1) - side_edges_2.extend(bottom_side_2) - base_edges.extend(bottom_loop) - - upper_side_1 = [[i + n for i in edges] for edges in bottom_side_1] - upper_side_2 = [[i + n for i in edges] for edges in bottom_side_2] - upper_loop = [[i + n for i in edges] for edges in bottom_loop] - side_edges_1.extend(upper_side_1) - side_edges_2.extend(upper_side_2) - base_edges.extend(upper_loop) - - loops = [side_edges_1, side_edges_2, base_edges] - - data["edges"] = [] - data["tris"] = [] - for i, group in enumerate(loops): - bm = bmesh.new() - - new_verts = [bm.verts.new(v) for v in points] - new_edges = [bm.edges.new((new_verts[e[0]], new_verts[e[1]])) for e in group] - - bm.verts.index_update() - bm.edges.index_update() - - if i == 2: - new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) - new_faces = bmesh.ops.bridge_loops(bm, edges=bm.edges, use_pairs=True, use_cyclic=True) - - bm.verts.index_update() - bm.edges.index_update() - edges = [[v.index for v in e.verts] for e in bm.edges] - tris = [[l.vert.index for l in loop] for loop in bm.calc_loop_triangles()] - data["edges"].extend(edges) - data["tris"].extend(tris) - - data["edges"] = list(set(tuple(e) for e in data["edges"])) - data["tris"] = list(set(tuple(t) for t in data["tris"])) - - return data - - -def get_slab_preview_data(context, relating_type): - model_props = tool.Model.get_model_props() - x_angle = 0 if tool.Cad.is_x(model_props.x_angle, 0, tolerance=0.001) else model_props.x_angle - direction_sense = model_props.direction_sense - direction = 1 - if direction_sense == "NEGATIVE": - direction = -1 - - layers = tool.Model.get_material_layer_parameters(relating_type) - if not layers["thickness"]: - return - thickness = layers["thickness"] * abs(1 / cos(x_angle)) - thickness *= direction - - offset_type = model_props.offset_type_horizontal - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - offset = model_props.offset * abs(1 / cos(x_angle)) * unit_scale - - data = {} - data["verts"] = [] - # Verts - polyline_vertices = [] - polyline_props = tool.Model.get_polyline_props() - polyline_data = polyline_props.insertion_polyline - polyline_points = polyline_data[0].polyline_points if polyline_data else [] - if len(polyline_points) < 3: - data = [] - return - for point in polyline_points: - polyline_vertices.append(Vector((point.x, point.y, point.z))) - if x_angle: - # Get vertices relative to the first polyline point as origin - local_vertices = [v - Vector(polyline_vertices[0]) for v in polyline_vertices] - # Make the transformation relative to the x_angle - transformed_vertices = [Vector((v.x, v.y * (1 / cos(x_angle)), v.z)) for v in local_vertices] - # Convert back to world origin - polyline_vertices = [v + Vector(polyline_vertices[0]) for v in transformed_vertices] - if offset != 0: - polyline_vertices = [v + Vector((0, 0, offset)) for v in polyline_vertices] - is_closed = True - if ( - polyline_vertices[0].x == polyline_vertices[-1].x - and polyline_vertices[0].y == polyline_vertices[-1].y - and polyline_vertices[0].z == polyline_vertices[-1].z - ): - polyline_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed. - bm = create_bmesh_from_vertices(polyline_vertices, is_closed) - bm.verts.ensure_lookup_table() - if x_angle: - rot_mat = Matrix.Rotation(x_angle, 3, "X") - if abs(x_angle) > (pi / 2): - rot_mat = rot_mat @ Matrix.Scale(-1, 3, (0, 1, 0)) - bmesh.ops.rotate(bm, cent=Vector(bm.verts[0].co), verts=bm.verts, matrix=rot_mat) - new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) - new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.edges[:] + bm.faces[:]) - new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)] - new_faces = bmesh.ops.translate(bm, verts=new_verts, vec=(0.0, 0.0, thickness)) - bm.verts.index_update() - bm.edges.index_update() - verts = [tuple(v.co) for v in bm.verts] - edges = [[v.index for v in e.verts] for e in bm.edges] - tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] - data["verts"] = verts - data["edges"] = edges - data["tris"] = tris - return data - - -def get_vertical_profile_preview_data( - context: bpy.types.Context, relating_type: ifcopenshell.entity_instance -) -> dict[str, Any]: - material = ifcopenshell.util.element.get_material(relating_type) - try: - profile = material.MaterialProfiles[0].Profile - except: - return {} - - model_props = tool.Model.get_model_props() - extrusion_depth = model_props.extrusion_depth - cardinal_point = model_props.cardinal_point - rot_mat = Quaternion() - if relating_type.is_a("IfcBeamType"): - y_rot = Quaternion((0.0, 1.0, 0.0), radians(90)) - z_rot = Quaternion((0.0, 0.0, 1.0), radians(90)) - rot_mat = y_rot @ z_rot - # Get profile data - settings = ifcopenshell.geom.settings() - settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - shape = ifcopenshell.geom.create_shape(settings, profile) - - verts = shape.verts - if not verts: - raise RuntimeError(f"Profile shape has no vertices, it probably is invalid: '{profile}'.") - - edges = shape.edges - - grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)] - grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)] - - # Create offsets based on cardinal point - min_x = min(v[0] for v in grouped_verts) - max_x = max(v[0] for v in grouped_verts) - min_y = min(v[1] for v in grouped_verts) - max_y = max(v[1] for v in grouped_verts) - - x_offset = (max_x - min_x) / 2 - y_offset = (max_y - min_y) / 2 - - match cardinal_point: - case "1": - grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] - case "2": - grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts] - case "3": - grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] - case "4": - grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts] - case "5": - grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts] - case "6": - grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts] - case "7": - grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] - case "8": - grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts] - case "9": - grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] - - # Create extrusion bmesh - bm = bmesh.new() - - grouped_verts.append(grouped_verts[0]) # Close profile - new_verts = [bm.verts.new(v) for v in grouped_verts] - new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(grouped_verts) - 1)] - - bm.verts.index_update() - bm.edges.index_update() - - bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.001) - - new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) - - new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.faces, use_dissolve_ortho_edges=True) - new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)] - new_faces = bmesh.ops.translate(bm, verts=new_verts, vec=(0.0, 0.0, extrusion_depth)) - - bm.verts.index_update() - bm.edges.index_update() - tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] - - # Calculate rotation, mouse position, angle and cardinal point - polyline_props = tool.Model.get_polyline_props() - snap_prop = polyline_props.snap_mouse_point[0] - mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) - data = {} - - verts = [tuple(v.co) for v in bm.verts] - verts = [tuple(rot_mat @ Vector(v)) for v in verts] - verts = [tuple(Vector(v) + mouse_point) for v in verts] - min_z = min(v.co.z for v in bm.verts) - max_z = max(v.co.z for v in bm.verts) - # Add axis verts - verts.append(tuple(mouse_point)) - verts.append(tuple(mouse_point + Vector((0, 0, max_z)))) - # Add only profile edges - edges = [] - for edge in bm.edges: - if (edge.verts[0].co.z == min_z and edge.verts[1].co.z == min_z) or ( - edge.verts[0].co.z == max_z and edge.verts[1].co.z == max_z - ): - edges.append(edge) - # Add axis edge - edges = [(edge.verts[0].index, edge.verts[1].index) for edge in edges] - edges.append((len(verts) - 1, len(verts) - 2)) - data["verts"] = verts - data["edges"] = edges - data["tris"] = tris - - bm.free() - - return data - - -def get_horizontal_profile_preview_data( - context: bpy.types.Context, relating_type: ifcopenshell.entity_instance -) -> dict[str, Any]: - material = ifcopenshell.util.element.get_material(relating_type) - try: - profile_curve = material.MaterialProfiles[0].Profile - except: - return {} - - model_props = tool.Model.get_model_props() - cardinal_point = model_props.cardinal_point - - polyline_verts = [] - polyline_props = tool.Model.get_polyline_props() - polyline_data = polyline_props.insertion_polyline - polyline_points = polyline_data[0].polyline_points if polyline_data else [] - if len(polyline_points) < 2: - return {} - for point in polyline_points: - polyline_verts.append(Vector((point.x, point.y, point.z))) - polyline_edges = [(i, i + 1) for i in range(len(polyline_verts) - 1)] - - # Get profile shape - settings = ifcopenshell.geom.settings() - settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - shape = ifcopenshell.geom.create_shape(settings, profile_curve) - - verts = shape.verts - if not verts: - raise RuntimeError(f"Profile shape has no vertices, it probably is invalid: '{profile_curve}'.") - - edges = shape.edges - - grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)] - grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)] - - # Create offsets based on cardinal point - min_x = min(v[0] for v in grouped_verts) - max_x = max(v[0] for v in grouped_verts) - min_y = min(v[1] for v in grouped_verts) - max_y = max(v[1] for v in grouped_verts) - - x_offset = (max_x - min_x) / 2 - y_offset = (max_y - min_y) / 2 - - match cardinal_point: - case "1": - grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] - case "2": - grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts] - case "3": - grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] - case "4": - grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts] - case "5": - grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts] - case "6": - grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts] - case "7": - grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] - case "8": - grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts] - case "9": - grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] - - data: dict[str, Any] = {} - data["verts"] = [] - data["edges"] = [] - data["tris"] = [] - - grouped_verts = [(v) for v in grouped_verts] - - all_bm = bmesh.new() - for i in range(len(polyline_verts) - 1): - mesh = bpy.data.meshes.new("TempMesh") - # Create the initial mesh from the profile verts - bm = create_bmesh_from_vertices(grouped_verts, is_closed=True) - bm.verts.ensure_lookup_table() - # Creates the clipping plane formed by two segments. - # The first one is for the profile start, based on the current and previous segment of the polyline. - # The second is for the profile end, based on the current and the next segment. - if i == 0: - d = (polyline_verts[i + 1] - polyline_verts[i]).normalized() - clip_start = d - else: - d1 = (polyline_verts[i] - polyline_verts[i - 1]).normalized() - d2 = (polyline_verts[i] - polyline_verts[i + 1]).normalized() - clip_start = (d1 - d2).normalized() - - if i == len(polyline_verts) - 2: - d = (polyline_verts[i + 1] - polyline_verts[i]).normalized() - clip_end = d - else: - d1 = (polyline_verts[i + 1] - polyline_verts[i]).normalized() - d2 = (polyline_verts[i + 1] - polyline_verts[i + 2]).normalized() - clip_end = (d1 - d2).normalized() - - # Rotates the profile face to the right direction - direction = polyline_verts[i + 1] - polyline_verts[i] - position = polyline_verts[i] - rotation_matrix = direction.to_track_quat("Z", "Y").to_matrix().to_4x4() - bmesh.ops.transform(bm, verts=bm.verts, matrix=rotation_matrix) - bmesh.ops.translate(bm, verts=bm.verts, vec=position) - bmesh.ops.translate(bm, verts=bm.verts, vec=-direction) - - # Extrude and move the new face - last_face = bmesh.ops.extrude_face_region(bm, geom=bm.edges[:] + bm.faces[:]) - new_verts = [e for e in last_face["geom"] if isinstance(e, bmesh.types.BMVert)] - bmesh.ops.translate(bm, verts=new_verts, vec=direction * 3) - # Apply the cutting planes - cut = bmesh.ops.bisect_plane( - bm, - geom=bm.verts[:] + bm.edges[:] + bm.faces[:], - plane_co=polyline_verts[i], - plane_no=clip_start, - clear_inner=True, - ) - bm.verts.index_update() - bm.edges.index_update() - cut = bmesh.ops.bisect_plane( - bm, - geom=bm.verts[:] + bm.edges[:] + bm.faces[:], - plane_co=polyline_verts[i + 1], - plane_no=clip_end, - clear_outer=True, - ) - - bm.to_mesh(mesh) - bm.free() - mesh.update() - all_bm.from_mesh(mesh) - bpy.data.meshes.remove(bpy.data.meshes["TempMesh"]) - - # It's necessary to add the mesh to an object to get the expected result. - mesh = bpy.data.meshes.new("TempMesh2") - all_bm.to_mesh(mesh) - all_bm.free() - obj = bpy.data.objects.new("TempObj", mesh) - bm = bmesh.new() - bm.from_mesh(obj.data) - bpy.data.meshes.remove(bpy.data.meshes["TempMesh2"]) - - verts = [tuple(v.co) for v in bm.verts] - edges = [[v.index for v in e.verts] for e in bm.edges] - tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] - data["verts"] = verts - data["edges"] = edges - data["tris"] = tris - bm.free() - return data - - -def get_generic_product_preview_data(context, relating_type): - model_props = tool.Model.get_model_props() - if relating_type.is_a("IfcDoorType"): - rl = float(model_props.rl1) - elif relating_type.is_a("IfcWindowType"): - rl = float(model_props.rl2) - else: - rl = 0 - polyline_props = tool.Model.get_polyline_props() - snap_prop = polyline_props.snap_mouse_point[0] - default_container_elevation = tool.Root.get_default_container_elevation() - mouse_point = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) - snap_obj = bpy.data.objects.get(snap_prop.snap_object) - snap_element = tool.Ifc.get_entity(snap_obj) - rot_mat = Matrix() - if relating_type.is_a() in ["IfcDoorType", "IfcWindowType"] and snap_element and snap_element.is_a("IfcWall"): - layers = tool.Model.get_material_layer_parameters(snap_element) - axes = tool.Model.get_wall_axis(snap_obj, layers=layers) - axis_base = axes["base"] - axis_side = axes["side"] - point_on_base_axis = tool.Cad.point_on_edge(mouse_point, axis_base) - point_on_side_axis = tool.Cad.point_on_edge(mouse_point, axis_side) - if (point_on_base_axis - mouse_point).length_squared <= (point_on_side_axis - mouse_point).length_squared: - # mouse is snapped to the base axis, the preview looks exactly like the placed door / window - rot_mat = snap_obj.matrix_world - else: - # mouse is snapped to the side axis, the preview is inverted, rotate it now and correct x position later - rot_mat = (snap_obj.matrix_world.to_quaternion() @ Quaternion(Vector((0, 0, 1)), radians(180))).to_matrix().to_4x4() - - mouse_point.z = snap_obj.matrix_world.translation.z - - if snap_element and (container := ifcopenshell.util.element.get_container(snap_element)): - container_obj = tool.Ifc.get_object(container) - mouse_point.z = container_obj.location.z - - obj_type = tool.Ifc.get_object(relating_type) - - subcontexts = tool.Drawing.get_active_drawing_subcontexts() - if not subcontexts: - subcontexts = [("Model", "Body", "MODEL_VIEW")] - - active_context = tool.Geometry.get_active_representation_context(obj_type) - active_context_params = tool.Geometry.get_subcontext_parameters(active_context) - for subcontext in subcontexts: - if subcontext == active_context_params: - break - - representation = ifcopenshell.util.representation.get_representation(relating_type, *subcontext) - if representation: - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj_type, - representation, - ) - context.view_layer.update() - break - - if obj_type.data: - data = ItemDecorator.get_obj_data(obj_type) - obj_type_matrix_i = obj_type.matrix_world.inverted() - translate_mouse = Matrix.Translation(mouse_point) - translate_rl = Matrix.Translation((0.0, 0.0, rl)) - combined_m = translate_mouse @ rot_mat @ translate_rl @ obj_type_matrix_i - data["verts"] = [tuple(combined_m @ Vector(v)) for v in data["verts"]] - return data - - class PolylineOperator: # TODO Fill doc strings """ """ @@ -989,37 +436,6 @@ class PolylineOperator: tool.Blender.update_viewport() return {"RUNNING_MODAL"} - def get_product_preview_data(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None: - if tool.Model.get_usage_type(relating_type) == "PROFILE": - if relating_type.is_a() in {"IfcColumnType", "IfcPileType"}: - data = get_vertical_profile_preview_data(context, relating_type) - else: - data = get_horizontal_profile_preview_data(context, relating_type) - elif tool.Model.get_usage_type(relating_type) == "LAYER2": - data = get_wall_preview_data(context, relating_type) - elif tool.Model.get_usage_type(relating_type) == "LAYER3": - data = get_slab_preview_data(context, relating_type) - else: - data = get_generic_product_preview_data(context, relating_type) - - # Update properties so it can be used by the decorator - props = tool.Model.get_product_preview_props() - props.verts.clear() - props.edges.clear() - props.tris.clear() - if not data: - return - - for vert in data["verts"]: - v = props.verts.add() - v.value_3d = vert - for edge in data["edges"]: - e = props.edges.add() - e.value_2d = edge - for tri in data["tris"]: - t = props.tris.add() - t.value_3d = tri - def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None: props = tool.Model.get_model_props() direction_sense = props.direction_sense diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index c08ff9131b..191a9406fe 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -249,8 +249,6 @@ class DrawOccurrence(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): if event.value == "RELEASE" and event.type == "LEFTMOUSE": self.create_occurrence(context, event) - self.get_product_preview_data(context, self.relating_type) - cancel = self.handle_cancelation(context, event) if cancel is not None: ProductDecorator.uninstall() diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index ecc1b86163..80c7ccc20e 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -1189,11 +1189,8 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato return {"FINISHED"} self.handle_keyboard_input(context, event) - self.handle_inserting_polyline(context, event) - self.get_product_preview_data(context, self.relating_type) - cancel = self.handle_cancelation(context, event) if cancel is not None: ProductDecorator.uninstall() diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index b26466aa15..4c3466d325 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -1697,17 +1697,6 @@ class ProductPreviewItem(PropertyGroup): value_2d: tuple[float, float] -class BIMProductPreviewProperties(PropertyGroup): - verts: bpy.props.CollectionProperty(type=ProductPreviewItem) - edges: bpy.props.CollectionProperty(type=ProductPreviewItem) - tris: bpy.props.CollectionProperty(type=ProductPreviewItem) - - if TYPE_CHECKING: - verts: bpy.types.bpy_prop_collection_idprop[ProductPreviewItem] - edges: bpy.types.bpy_prop_collection_idprop[ProductPreviewItem] - tris: bpy.types.bpy_prop_collection_idprop[ProductPreviewItem] - - def update_is_editing(self: "BIMExternalParametricGeometryProperties", context: bpy.types.Context) -> None: if self.is_editing: return diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 4c9e89bf41..b6046df18d 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -1135,11 +1135,8 @@ class DrawPolylineSlab(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): return {"FINISHED"} self.handle_keyboard_input(context, event) - self.handle_inserting_polyline(context, event) - self.get_product_preview_data(context, self.relating_type) - cancel = self.handle_cancelation(context, event) if cancel is not None: ProductDecorator.uninstall() diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index d652041125..d343bc75ae 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -787,11 +787,8 @@ class DrawPolylineWall(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): return {"FINISHED"} self.handle_keyboard_input(context, event) - self.handle_inserting_polyline(context, event) - self.get_product_preview_data(context, self.relating_type) - cancel = self.handle_cancelation(context, event) if cancel is not None: ProductDecorator.uninstall() diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 2f4c4b77b7..a81681652c 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -66,7 +66,6 @@ if TYPE_CHECKING: BIMRailingProperties, BIMExternalParametricGeometryProperties, BIMPolylineProperties, - BIMProductPreviewProperties, ) @@ -108,11 +107,6 @@ class Model(bonsai.core.tool.Model): assert (scene := bpy.context.scene) return scene.BIMPolylineProperties # pyright: ignore[reportAttributeAccessIssue] - @classmethod - def get_product_preview_props(cls) -> BIMProductPreviewProperties: - assert (scene := bpy.context.scene) - return scene.BIMProductPreviewProperties # pyright: ignore[reportAttributeAccessIssue] - @classmethod def convert_si_to_unit(cls, value: T) -> T: if isinstance(value, (tuple, list)): @@ -2750,3 +2744,20 @@ class Model(bonsai.core.tool.Model): list(nodes_to_update) finally: SvIfcStore.use_bonsai_file = False + + @classmethod + def create_bmesh_from_vertices(cls, vertices, is_closed=False): + bm = bmesh.new() + + new_verts = [bm.verts.new(v) for v in vertices] + if is_closed: + new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] + new_edges.append( + bm.edges.new((new_verts[-1], new_verts[0])) + ) # Add an edge between the last an first point to make it closed. + else: + new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] + + bm.verts.index_update() + bm.edges.index_update() + return bm From db52703ce317c152b0ee677ee4505fde352b9463 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 21 Jan 2026 18:52:27 +1100 Subject: [PATCH 19/37] Revert "Use hide_set() for IfcSpaces instead of hide_viewport" This reverts commit 96fe9b5398e26f6d53c3e7e0dadd4dfc67acf514. This commit contains a logical error where hide_viewport is checked in the if statement but hide_set() is used as the effect. This causes a regression in tests about project visibility. `hide_viewport` is the correct hiding setting to use, because hide_set is more ephemeral and affected by Shift-H, Alt-H etc. --- src/bonsai/bonsai/bim/import_ifc.py | 8 -------- src/bonsai/bonsai/tool/collector.py | 6 ++++-- src/bonsai/bonsai/tool/spatial.py | 8 ++++---- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 58f6a67065..04dd612a8b 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -290,7 +290,6 @@ class IfcImporter: self.profile_code("Load linked models") self.add_project_to_scene() self.profile_code("Add project to scene") - self.hide_ifc_spaces() if self.ifc_import_settings.should_clean_mesh and len(self.file.by_type("IfcElement")) < 1000: self.clean_mesh() self.profile_code("Mesh cleaning") @@ -1287,13 +1286,6 @@ class IfcImporter: properties={"Aggregate_Index": aggregate_index, "Name": name}, ) - def hide_ifc_spaces(self): - """Hide IfcSpace objects after they've been added to the scene.""" - for ifc_definition_id, obj in self.added_data.items(): - if isinstance(obj, bpy.types.Object): - element = self.file.by_id(ifc_definition_id) - if element.is_a("IfcSpace"): - obj.hide_set(True) class IfcImportSettings: diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index 7c46135b26..c357287a6b 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -50,12 +50,12 @@ class Collector(bonsai.core.tool.Collector): tool.Geometry.lock_object(obj) element = (element.PartOfU or element.PartOfV or element.PartOfW)[0] if not tool.Spatial.get_grid_props().is_visible: - obj.hide_set(True) + obj.hide_viewport = True elif element.is_a("IfcGrid"): if tool.Geometry.is_locked(element): tool.Geometry.lock_object(obj) if not tool.Spatial.get_grid_props().is_visible: - obj.hide_set(True) + obj.hide_viewport = True if element.is_a("IfcProject"): if tool.Geometry.is_locked(element): @@ -71,6 +71,8 @@ class Collector(bonsai.core.tool.Collector): tool.Geometry.lock_object(obj) collection = cls._create_project_child_collection("IfcSpace") cls.link_collection_object_safe(collection, obj) + if not tool.Spatial.get_spatial_props().is_visible: + obj.hide_viewport = True elif element.is_a("IfcStructuralItem"): collection = cls._create_project_child_collection("IfcStructuralItem") cls.link_collection_object_safe(collection, obj) diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index 6ca29343e9..a177a237e8 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -1213,18 +1213,18 @@ class Spatial(bonsai.core.tool.Spatial): for element in elements: if obj := tool.Ifc.get_object(element): if obj.hide_viewport is True and is_visible: - obj.hide_set(False) + obj.hide_viewport = False elif obj.hide_viewport is False and not is_visible: - obj.hide_set(True) + obj.hide_viewport = True @classmethod def set_grid_visibility(cls, is_visible: bool) -> None: for element in tool.Ifc.get().by_type("IfcGrid") + tool.Ifc.get().by_type("IfcGridAxis"): if obj := tool.Ifc.get_object(element): if obj.hide_viewport is True and is_visible: - obj.hide_set(False) + obj.hide_viewport = False elif obj.hide_viewport is False and not is_visible: - obj.hide_set(True) + obj.hide_viewport = True @classmethod def toggle_spaces_visibility_wired_and_textured(cls, spaces: list[ifcopenshell.entity_instance]) -> None: From 41c6eba193230b7d12f03b8feedcb6bfcfdd1709 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 21 Jan 2026 19:43:37 +1100 Subject: [PATCH 20/37] IfcTester should not check requirements for prohibited specifications --- src/bonsai/bonsai/bim/import_ifc.py | 1 - src/ifctester/ifctester/ids.py | 16 ++++------------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 04dd612a8b..05bcbed01a 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -1287,7 +1287,6 @@ class IfcImporter: ) - class IfcImportSettings: """ Initialize only using `IfcImportSettings.factory()`. diff --git a/src/ifctester/ifctester/ids.py b/src/ifctester/ifctester/ids.py index ea21aa15fd..d7403e2594 100644 --- a/src/ifctester/ifctester/ids.py +++ b/src/ifctester/ifctester/ids.py @@ -300,23 +300,15 @@ class Specification: if not is_applicable: continue self.applicable_entities.append(element) - for facet in self.requirements: - result = facet(element) - is_pass = bool(result) - if self.maxOccurs != 0: # This is a required or optional specification - if is_pass: + if self.maxOccurs != 0: # Requirements are skipped for prohibited applicability + for facet in self.requirements: + result = facet(element) + if bool(result): self.passed_entities.add(element) facet.passed_entities.add(element) else: self.failed_entities.add(element) facet.failures.append(FacetFailure(element=element, reason=str(result))) - else: # This is a prohibited specification - if is_pass: - self.failed_entities.add(element) - facet.failures.append(FacetFailure(element=element, reason=str(result))) - else: - self.passed_entities.add(element) - facet.passed_entities.add(element) self.status = True for facet in self.requirements: From c33969d589a7d9db044170513183dc80b7fce382 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 21 Jan 2026 16:24:11 +0500 Subject: [PATCH 21/37] ifcsverchok - correct socket type for position --- src/ifcsverchok/nodes/ifc/shape_builder/extrude.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py b/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py index 3b984dd4d7..44fd47cebc 100644 --- a/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py +++ b/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Literal import bpy import ifcopenshell from ifcopenshell.util.shape_builder import ShapeBuilder +from sverchok.core.sockets import SvVerticesSocket from sverchok.data_structure import updateNode from sverchok.node_tree import SverchCustomTreeNode @@ -62,6 +63,7 @@ class SvIfcSbExtrude(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.Sv self.inputs, "Position", data_type="list[list[tuple[float, float, float]]]", + socket_type=SvVerticesSocket, ) helper.create_socket( self.outputs, From 784415e464e87a752e5da4d8811513b749583723 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 21 Jan 2026 16:44:07 +0500 Subject: [PATCH 22/37] ifcsverchok bonsai integration - support editing parametric properties from UI Demo - https://files.catbox.moe/extsxf.mp4 But now it requires ifcsverchok graph to be nested inside a subgroup because we need a way to indicate which graph parameters are important, so user can modify them from Parametric Geometry tab without actually explicitly opening sverchok graph. --- src/bonsai/bonsai/bim/module/model/ui.py | 9 +++++++++ src/bonsai/bonsai/tool/model.py | 11 ++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index 86beea6079..ab528fb0fc 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -694,6 +694,15 @@ class BIM_PT_external_parametric_geometry(bpy.types.Panel): print(res) layout.label(text=f"Error Updating from Graph, See System Console", icon="ERROR") + layout.label(text="Parameters:") + box = layout.box() + + group_node = tool.Model.get_ifcsverchok_group_node(props.sverchok_nodes) + node_tree = group_node.node_tree + + for socket, interface_socket in zip(group_node.inputs, node_tree.sockets("INPUT")): + socket.draw_group_property(box, socket.name, interface_socket) + def draw_door_properties(layout: bpy.types.UILayout, props: module_prop.BIMDoorProperties) -> None: """Draw door properties UI (shared between properties panel and preferences).""" diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index a81681652c..28506bd8db 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -67,6 +67,7 @@ if TYPE_CHECKING: BIMExternalParametricGeometryProperties, BIMPolylineProperties, ) + from sverchok.core.node_group import SvGroupTreeNode class Model(bonsai.core.tool.Model): @@ -2565,13 +2566,21 @@ class Model(bonsai.core.tool.Model): return [s for s in group_node.inputs if s.type != "GEOMETRY"] + @classmethod + def get_ifcsverchok_group_node(cls, node_tree: sverchok.node_tree.SverchCustomTree) -> SvGroupTreeNode: + from sverchok.core.node_group import SvGroupTreeNode + + return next(n for n in node_tree.nodes if isinstance(n, SvGroupTreeNode) and n.label == "BBIM_EPG") + @classmethod def get_ifcsverchok_shape_output( cls, node_tree: sverchok.node_tree.SverchCustomTree ) -> ifcsverchok.nodes.ifc.shape_builder.shape_output.SvSbShapeOutput: from ifcsverchok.nodes.ifc.shape_builder.shape_output import SvSbShapeOutput - return next(n for n in node_tree.nodes if isinstance(n, SvSbShapeOutput)) + group_node = cls.get_ifcsverchok_group_node(node_tree) + subtree = group_node.node_tree + return next(n for n in subtree.nodes if isinstance(n, SvSbShapeOutput)) @classmethod def update_mesh_from_sverchok( From 64865c58a5677fac183dd357615dd56614fadbe5 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 21 Jan 2026 16:47:33 +0500 Subject: [PATCH 23/37] remove debug print --- src/bonsai/bonsai/bim/module/model/ui.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index ab528fb0fc..0d9aea58cd 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -691,7 +691,6 @@ class BIM_PT_external_parametric_geometry(bpy.types.Panel): # should find a way to update only on graph changes. res = tool.Model.update_mesh_from_sverchok(obj, props.sverchok_nodes) if res is not None: - print(res) layout.label(text=f"Error Updating from Graph, See System Console", icon="ERROR") layout.label(text="Parameters:") From 421d454ce82bc896f16ee8519ef660178cb8887f Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 21 Jan 2026 17:38:26 +0500 Subject: [PATCH 24/37] Fix missing `Literal` import --- src/bonsai/bonsai/bim/module/model/decorator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 43d770828e..e9435eea99 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -35,7 +35,7 @@ from gpu_extras.presets import draw_circle_2d from typing import Union from bonsai.bim.module.drawing.helper import format_distance from itertools import chain -from typing import Union, Any +from typing import Union, Any, Literal from bpy_extras.view3d_utils import location_3d_to_region_2d From 0cc3ce870924a41ae5abf9456a939a62f186229d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 21:19:22 +0000 Subject: [PATCH 25/37] Bump tar from 7.5.3 to 7.5.6 in /src/ifctester/webapp Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.3 to 7.5.6. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v7.5.3...v7.5.6) --- updated-dependencies: - dependency-name: tar dependency-version: 7.5.6 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- src/ifctester/webapp/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ifctester/webapp/package-lock.json b/src/ifctester/webapp/package-lock.json index 9b7f5b6058..35d03c74fa 100644 --- a/src/ifctester/webapp/package-lock.json +++ b/src/ifctester/webapp/package-lock.json @@ -2851,9 +2851,9 @@ } }, "node_modules/tar": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz", - "integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==", + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz", + "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { From f89cf88bc0974db51384b863c059eeee6e5aee41 Mon Sep 17 00:00:00 2001 From: falken10vdl <33285113+falken10vdl@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:26:18 +0100 Subject: [PATCH 26/37] Add use_factory_startup to src/bonsai/test/bim/test_feature.py def an_empty_blender_session() This addresses https://github.com/IfcOpenShell/IfcOpenShell/issues/7590 Cheers! --- src/bonsai/test/bim/test_feature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index 64d7e7cb27..036e0bf464 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -337,7 +337,7 @@ def an_untestable_scenario(): def an_empty_blender_session(): IfcStore.purge() if not PYTEST_BLENDER_NO_BACKGROUND: - bpy.ops.wm.read_homefile(app_template="") + bpy.ops.wm.read_homefile(app_template="", use_factory_startup=True) if len(bpy.data.objects) > 0: bpy.data.batch_remove(bpy.data.objects) bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) From aafa1b95e854f9881202e071c3e6271fcf28b44a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20Mell=C3=BCh?= <74136980+c-mellueh@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:19:09 +0100 Subject: [PATCH 27/37] bSDD Add new class properties and relations API Endpoints (#7502) * black . * add typing * move function * add get_class_relations * add test for class relation --------- Co-authored-by: Dion Moult --- src/bsdd/bsdd.py | 122 ++++++++++++++++++++++++++++++++++++ src/bsdd/tests/test_bsdd.py | 16 +++++ 2 files changed, 138 insertions(+) diff --git a/src/bsdd/bsdd.py b/src/bsdd/bsdd.py index 87154855ae..ff633095bf 100644 --- a/src/bsdd/bsdd.py +++ b/src/bsdd/bsdd.py @@ -184,6 +184,78 @@ class ClassPropertyContractV1(TypedDict): qudtCodes: NotRequired[list[str]] +class ClassRelationItemContractV1(TypedDict): + relationType: str + classUri: str + className: NotRequired[str] + fraction: NotRequired[float] + dictionaryUri: NotRequired[str] + + +class ClassRelationsContractV1(TypedDict): + totalCount: NotRequired[int] + offset: NotRequired[int] + count: NotRequired[int] + classUri: NotRequired[str] + areReversedRelations: NotRequired[bool] + classRelations: NotRequired[list[ClassRelationItemContractV1]] + + +class ClassPropertiesContractV1(TypedDict): + classUri: NotRequired[str] + totalCount: NotRequired[int] + offset: NotRequired[int] + count: NotRequired[int] + classProperties: list[ClassPropertyContractV1] + + +class ClassPropertyItemContractV1(TypedDict): + name: str + propertySet: str + uri: str + description: NotRequired[str] + definition: NotRequired[str] + dataType: NotRequired[str] + dimension: NotRequired[str] + dimensionLength: NotRequired[int] + dimensionMass: NotRequired[int] + dimensionTime: NotRequired[int] + dimensionElectricCurrent: NotRequired[int] + dimensionThermodynamicTemperature: NotRequired[int] + dimensionAmountOfSubstance: NotRequired[int] + dimensionLuminousIntensity: NotRequired[int] + dynamicParameterPropertyCodes: NotRequired[list[str]] + example: NotRequired[str] + isDynamic: NotRequired[bool] + isRequired: NotRequired[bool] + isWritable: NotRequired[bool] + maxExclusive: NotRequired[float] + maxInclusive: NotRequired[float] + minExclusive: NotRequired[float] + minInclusive: NotRequired[float] + pattern: NotRequired[str] + physicalQuantity: NotRequired[str] + allowedValues: NotRequired[list[ClassPropertyValueItemContractV1]] + predefinedValue: NotRequired[str] + propertyCode: NotRequired[str] + propertyDictionaryName: NotRequired[str] + propertyDictionaryUri: NotRequired[str] + propertyUri: NotRequired[str] + propertyStatus: NotRequired[str] + propertyValueKind: NotRequired[str] + symbol: NotRequired[str] + units: NotRequired[list[str]] + qudtCodes: NotRequired[list[str]] + + +class ClassPropertyValueItemContractV1(TypedDict): + uri: NotRequired[str] + code: NotRequired[str] + value: str + description: NotRequired[str] + sortNumber: NotRequired[int] + + class PropertyContractV5(TypedDict): dictionaryUri: NotRequired[str] activationDateUtc: str @@ -710,6 +782,56 @@ class Client: params = {k: v for k, v in params.items() if v is not None} return self.get(endpoint, params) + def get_class_relations( + self, + class_uri: str, + get_reverse_relations: bool = False, + search_text: str = "", + offset: int = 0, + limit: int = 1000, + language_code: str = "", + version=1, + ) -> ClassRelationsContractV1: + """ + Get class relations or reverse relations (paginated) + """ + endpoint = f"Class/Relations/v{version}" + params = { + "ClassUri": class_uri, + "GetReverseRelations": get_reverse_relations, + "SearchText": search_text, + "Offset": offset, + "Limit": limit, + "languageCode": language_code, + } + return self.get(endpoint, params) + + def get_class_properties( + self, + class_uri: str, + property_set: str = "", + property_code: str = "", + search_text: str = "", + offset: int = 0, + limit: int = 1000, + language_code: str = "", + version=1, + ) -> ClassPropertiesContractV1: + """ + Get class properties (paginated) + """ + endpoint = f"Class/Properties/v{version}" + params = { + "ClassUri": class_uri, + "PropertySet": property_set, + "PropertyCode": property_code, + "SearchText": search_text, + "Offset": offset, + "Limit": limit, + "languageCode": language_code, + } + return self.get(endpoint, params) + def get_property(self, uri, language_code="", version: int = 5) -> PropertyContractV5: """ Get Property details. diff --git a/src/bsdd/tests/test_bsdd.py b/src/bsdd/tests/test_bsdd.py index aea17b9eb9..9dab01025c 100644 --- a/src/bsdd/tests/test_bsdd.py +++ b/src/bsdd/tests/test_bsdd.py @@ -37,6 +37,22 @@ def test_get_class(): ] +def test_get_class_relations(): + uri_light_fixture = next(l for l in get_ifc_classes()["classes"] if "IfcLightFixture" == l["code"])["uri"] + ifc4x3_light_fixture_relations = client.get_class_properties(uri_light_fixture, True) + assert "Electrical unit for light-line system" and "Tubelight system" in [ + r["className"] for r in ifc4x3_light_fixture_relations["classRelations"] + ] + + +def test_get_class_properties(): + uri_light_fixture = next(l for l in get_ifc_classes()["classes"] if "IfcLightFixture" == l["code"])["uri"] + ifc4x3_light_fixture_properties = client.get_class_properties(uri_light_fixture) + assert "Maintenance Factor" and "Light Fixture Mounting Type" in [ + l["name"] for l in ifc4x3_light_fixture_properties["classProperties"] + ] + + def test_search_class(): ss_heat_pump_sys = client.search_class("Ss_60_40_36", [nbs_uri]) li = [l + "source heat pump systems" for l in ["Air ", "Ground ", "Water "]] From a3801a0ce96ef42f5b1819705309ddf6b15163a2 Mon Sep 17 00:00:00 2001 From: Vincent Cadoret Date: Wed, 24 Dec 2025 16:22:26 -0500 Subject: [PATCH 28/37] Fix UnboundLocalError in reporter.py cardinality assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report_specification() method in the Json reporter class was raising an UnboundLocalError when processing IDS specifications with certain minOccurs/maxOccurs combinations that weren't explicitly handled. Problem: The cardinality variable was only assigned for three specific cases: - minOccurs=1, maxOccurs="unbounded" → "required" - minOccurs=0, maxOccurs="unbounded" → "optional" - minOccurs=0, maxOccurs=0 → "prohibited" However, the IDS schema allows other valid combinations such as: - minOccurs=0, maxOccurs=1 (commonly used for optional specifications) - minOccurs=1, maxOccurs=1 (exactly one occurrence required) - Any other valid XML Schema cardinality values When processing IDS files with these combinations, the cardinality variable remained unassigned, causing an UnboundLocalError at line 382 when attempting to use it in ResultsSpecification(). Solution: Added fallback logic to handle all valid IDS cardinality combinations: - If minOccurs >= 1: cardinality = "required" (must occur at least once) - Otherwise (minOccurs == 0): cardinality = "optional" (may occur) This maintains semantic compatibility with the existing codebase, which expects cardinality to be one of the semantic strings ("required", "optional", "prohibited") rather than numeric ranges. This is critical for: - HTML template rendering (line 457: .capitalize()) - Conditional logic for skipped specs (line 454) - UI rendering for prohibited specs (line 456) Testing: - Tested with IDS file containing minOccurs=0 without explicit maxOccurs (defaults to 1 per XML Schema specification) - Validation now completes successfully without UnboundLocalError - HTML report generation works correctly with semantic cardinality labels - Maintains backward compatibility with existing IDS files Fixes: Validation failure when using valid IDS cardinality combinations --- src/ifctester/ifctester/reporter.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ifctester/ifctester/reporter.py b/src/ifctester/ifctester/reporter.py index 220d0f96a9..84684a10a2 100644 --- a/src/ifctester/ifctester/reporter.py +++ b/src/ifctester/ifctester/reporter.py @@ -367,6 +367,12 @@ class Json(Reporter): cardinality = "optional" elif specification.minOccurs == 0 and specification.maxOccurs == 0: cardinality = "prohibited" + elif specification.minOccurs >= 1: + # Any minimum occurrence >= 1 means the specification is required + cardinality = "required" + else: + # minOccurs == 0 with any other maxOccurs value means optional + cardinality = "optional" return ResultsSpecification( name=specification.name, From 4da0b04162aa98784ca768184148b0ee1bea15e8 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 28 Jul 2025 23:48:36 +0200 Subject: [PATCH 29/37] add adjunt_overlapping_labels --- .../bonsai/bim/module/model/decorator.py | 147 +++++++++++++----- 1 file changed, 108 insertions(+), 39 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index e9435eea99..b45c50b623 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -521,6 +521,10 @@ class PolylineDecorator: color = self.addon_prefs.decorations_colour blf.color(self.font_id, *color) + + screen_coords = {} + label_index = 0 + for i in range(len(self.polyline_points)): if i < 1 and self.measure_type == "POLY_AREA": continue @@ -529,39 +533,39 @@ class PolylineDecorator: dim_text_pos = (Vector(self.polyline_points[i].position) + Vector(self.polyline_points[i - 1].position)) / 2 dim_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, dim_text_pos) - formatted_value = self.polyline_points[i].dim + if dim_text_coords: + formatted_value = self.polyline_points[i].dim + text = "d: " + formatted_value + screen_coords[f"distance_{label_index}"] = (dim_text_coords, text) + label_index += 1 - blf.position(self.font_id, dim_text_coords[0], dim_text_coords[1], 0) - text = "d: " + formatted_value - text_length = blf.dimensions(self.font_id, text) - self.draw_text_background(context, dim_text_coords, text_length) - blf.draw(self.font_id, text) + if self.measure_type != "SINGLE": + for i in range(len(self.polyline_points)): + if i ==1: + continue - if i == 1: - continue - angle_text_pos = Vector(self.polyline_points[i - 1].position) - angle_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, angle_text_pos) - blf.position(self.font_id, angle_text_coords[0], angle_text_coords[1], 0) - text = "a: " + self.polyline_points[i].angle - text_length = blf.dimensions(self.font_id, text) - self.draw_text_background(context, angle_text_coords, text_length) - blf.draw(self.font_id, text) + angle_text_pos = Vector(self.polyline_points[i - 1].position) + angle_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, angle_text_pos) + + if angle_text_coords: + text = "a: " + self.polyline_points[i].angle + screen_coords[f"angle_{label_index}"] = (angle_text_coords, text) + label_index += 1 if self.measure_type == "SINGLE": axis_line, axis_line_center = self.calculate_measurement_x_y_and_z(context) - for i, dim_text_pos in enumerate(axis_line_center): - dim_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, dim_text_pos) - pos = blf.position(self.font_id, dim_text_coords[0], dim_text_coords[1], 0) - value = round((axis_line[i][1] - axis_line[i][0]).length, 4) - direction = axis_line[i][1] - axis_line[i][0] - if (i == 0 and direction.x < 0) or (i == 1 and direction.y < 0) or (i == 2 and direction.z < 0): - value = -value - prefix = "xyz"[i] - formatted_value = tool.Polyline.format_input_ui_units(value) - text = f"{prefix}: {formatted_value}" - text_length = blf.dimensions(self.font_id, text) - self.draw_text_background(context, dim_text_coords, text_length) - blf.draw(self.font_id, text) + if axis_line and axis_line_center: + for i, dim_text_pos in enumerate(axis_line_center): + dim_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, dim_text_pos) + if dim_text_coords: + value = round((axis_line[i][1] - axis_line[i][0]).length, 4) + direction = axis_line[i][1] - axis_line[i][0] + if (i == 0 and direction.x < 0) or (i == 1 and direction.y < 0) or (i == 2 and direction.z < 0): + value = -value + prefix = "xyz"[i] + formatted_value = tool.Polyline.format_input_ui_units(value) + text = f"{prefix}: {formatted_value}" + screen_coords[f"xyz_{i}"] = (dim_text_coords, text) # Area and Length text polyline_verts = [Vector((p.x, p.y, p.z)) for p in self.polyline_points] @@ -576,28 +580,93 @@ class PolylineDecorator: polyline_verts[:-1] ) # Doesn't use the last point if is a closed polyline area_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, center) - value = self.polyline_data.area - text = f"area: {value}" - text_length = blf.dimensions(self.font_id, text) - area_text_coords[0] -= text_length[0] / 2 # Center text horizontally - blf.position(self.font_id, area_text_coords[0], area_text_coords[1], 0) - self.draw_text_background(context, area_text_coords, text_length) - blf.draw(self.font_id, text) + if area_text_coords: + value = self.polyline_data.area + text = f"area: {value}" + screen_coords["area"] = (area_text_coords, text) # Length if self.measure_type in {"POLYLINE", "POLY_AREA"}: if len(polyline_verts) < 3: return total_length_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, polyline_verts[-1]) - blf.position(self.font_id, total_length_text_coords[0], total_length_text_coords[1], 0) - value = self.polyline_data.total_length - text = f"length: {value}" + if total_length_text_coords: + value = self.polyline_data.total_length + text = f"length: {value}" + screen_coords["length"] = (total_length_text_coords, text) + + self.adjust_overlapping_labels(screen_coords) + + for label_key, (screen_co, text) in screen_coords.items(): + blf.position(self.font_id, screen_co.x, screen_co.y, 0) + blf.color(self.font_id, 1, 1, 1, 1) text_length = blf.dimensions(self.font_id, text) - self.draw_text_background(context, total_length_text_coords, text_length) + self.draw_text_background(context, screen_co, text_length) blf.draw(self.font_id, text) blf.disable(self.font_id, blf.SHADOW) + def adjust_overlapping_labels(self, screen_coords): + font_id = self.font_id + text_dimensions = {} + + for label_key, (screen_co, text) in screen_coords.items(): + text_dimensions[label_key] = blf.dimensions(font_id, text) + + min_spacing = 12 + + label_keys = list(screen_coords.keys()) + for pass_num in range(3): # 3 passes to try to optimize complex overlaps + for i in range(len(label_keys)): + for j in range(i + 1, len(label_keys)): + key1, key2 = label_keys[i], label_keys[j] + co1, _ = screen_coords[key1] + co2, _ = screen_coords[key2] + dim1 = text_dimensions[key1] + dim2 = text_dimensions[key2] + + bounds1 = { + "left": co1.x - min_spacing, + "right": co1.x + dim1[0] + min_spacing, + "top": co1.y + dim1[1] + min_spacing, + "bottom": co1.y - min_spacing, + } + bounds2 = { + "left": co2.x - min_spacing, + "right": co2.x + dim2[0] + min_spacing, + "top": co2.y + dim2[1] + min_spacing, + "bottom": co2.y - min_spacing, + } + + if ( + bounds1["left"] < bounds2["right"] + and bounds1["right"] > bounds2["left"] + and bounds1["bottom"] < bounds2["top"] + and bounds1["top"] > bounds2["bottom"] + ): + x_overlap = min(bounds1["right"], bounds2["right"]) - max(bounds1["left"], bounds2["left"]) + y_overlap = min(bounds1["top"], bounds2["top"]) - max(bounds1["bottom"], bounds2["bottom"]) + + separation_multiplier = 1.25 + + # Move labels in the direction requiring less movement + if x_overlap < y_overlap: + separation_distance = (x_overlap / 2 + min_spacing) * separation_multiplier + if co1.x < co2.x: + co1.x -= separation_distance + co2.x += separation_distance + else: + co1.x += separation_distance + co2.x -= separation_distance + else: + separation_distance = (y_overlap / 2 + min_spacing) * separation_multiplier + if co1.y < co2.y: + co1.y -= separation_distance + co2.y += separation_distance + else: + co1.y += separation_distance + co2.y -= separation_distance + def draw_measurements_poly(self, context): self.shader_config(context) polyline_verts: list[Vector] = [] From 3c3525f205bfb3adf0a080f0a108b0ef1d08df13 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Thu, 25 Sep 2025 00:53:00 +0200 Subject: [PATCH 30/37] display angles and area/length values conditionally based on polyline points --- .../bonsai/bim/module/model/decorator.py | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index b45c50b623..54861e6591 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -539,19 +539,19 @@ class PolylineDecorator: screen_coords[f"distance_{label_index}"] = (dim_text_coords, text) label_index += 1 - if self.measure_type != "SINGLE": + # --- Show angles for polyline/poly area --- + if self.measure_type != "SINGLE" and len(self.polyline_points) > 2: for i in range(len(self.polyline_points)): - if i ==1: + if i == 0 or i == len(self.polyline_points) - 1: continue - - angle_text_pos = Vector(self.polyline_points[i - 1].position) + angle_text_pos = Vector(self.polyline_points[i].position) angle_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, angle_text_pos) - if angle_text_coords: - text = "a: " + self.polyline_points[i].angle - screen_coords[f"angle_{label_index}"] = (angle_text_coords, text) - label_index += 1 + text = "a: " + self.polyline_points[i+1].angle + screen_coords[f"angle_{i}"] = (angle_text_coords, text) + + # --- Show XYZ axis values for single measurement --- if self.measure_type == "SINGLE": axis_line, axis_line_center = self.calculate_measurement_x_y_and_z(context) if axis_line and axis_line_center: @@ -567,33 +567,29 @@ class PolylineDecorator: text = f"{prefix}: {formatted_value}" screen_coords[f"xyz_{i}"] = (dim_text_coords, text) - # Area and Length text + #Area and Length text polyline_verts = [Vector((p.x, p.y, p.z)) for p in self.polyline_points] # Area if self.measure_type == "POLY_AREA" and self.polyline_data.area: - if len(polyline_verts) < 3: - return - center = sum(polyline_verts, Vector()) / len(polyline_verts) # Center between all polyline points - if polyline_verts[0] == polyline_verts[-1]: - center = sum(polyline_verts[:-1], Vector()) / len( - polyline_verts[:-1] - ) # Doesn't use the last point if is a closed polyline - area_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, center) - if area_text_coords: - value = self.polyline_data.area - text = f"area: {value}" - screen_coords["area"] = (area_text_coords, text) + if len(polyline_verts) >= 3: + center = sum(polyline_verts, Vector()) / len(polyline_verts) + if polyline_verts[0] == polyline_verts[-1]: + center = sum(polyline_verts[:-1], Vector()) / len(polyline_verts[:-1]) + area_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, center) + if area_text_coords: + value = self.polyline_data.area + text = f"area: {value}" + screen_coords["area"] = (area_text_coords, text) # Length if self.measure_type in {"POLYLINE", "POLY_AREA"}: - if len(polyline_verts) < 3: - return - total_length_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, polyline_verts[-1]) - if total_length_text_coords: - value = self.polyline_data.total_length - text = f"length: {value}" - screen_coords["length"] = (total_length_text_coords, text) + if len(polyline_verts) >= 3: + total_length_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, polyline_verts[-1]) + if total_length_text_coords: + value = self.polyline_data.total_length + text = f"length: {value}" + screen_coords["length"] = (total_length_text_coords, text) self.adjust_overlapping_labels(screen_coords) From 587fc638432da98a489308a6fef8128db38184d4 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Thu, 25 Sep 2025 09:05:32 +0200 Subject: [PATCH 31/37] Refactor PolylineDecorator to simplify label indexing and improve text spacing calculation --- src/bonsai/bonsai/bim/module/model/decorator.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 54861e6591..9152743e12 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -523,7 +523,6 @@ class PolylineDecorator: blf.color(self.font_id, *color) screen_coords = {} - label_index = 0 for i in range(len(self.polyline_points)): if i < 1 and self.measure_type == "POLY_AREA": @@ -536,9 +535,7 @@ class PolylineDecorator: if dim_text_coords: formatted_value = self.polyline_points[i].dim text = "d: " + formatted_value - screen_coords[f"distance_{label_index}"] = (dim_text_coords, text) - label_index += 1 - + screen_coords[f"distance_{i}"] = (dim_text_coords, text) # --- Show angles for polyline/poly area --- if self.measure_type != "SINGLE" and len(self.polyline_points) > 2: for i in range(len(self.polyline_points)): @@ -609,7 +606,11 @@ class PolylineDecorator: for label_key, (screen_co, text) in screen_coords.items(): text_dimensions[label_key] = blf.dimensions(font_id, text) - min_spacing = 12 + if text_dimensions: + first_height = next(iter(text_dimensions.values()))[1] + min_spacing = max(2, first_height * 0.3) + else: + min_spacing = 2 label_keys = list(screen_coords.keys()) for pass_num in range(3): # 3 passes to try to optimize complex overlaps From 84d55d896916afa3116f11a22f1b2a00dfb88481 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Thu, 25 Sep 2025 09:38:24 +0200 Subject: [PATCH 32/37] cleanup --- .../bonsai/bim/module/model/decorator.py | 83 ++++++++++--------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 9152743e12..00e733b397 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -531,62 +531,63 @@ class PolylineDecorator: continue dim_text_pos = (Vector(self.polyline_points[i].position) + Vector(self.polyline_points[i - 1].position)) / 2 dim_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, dim_text_pos) - if dim_text_coords: formatted_value = self.polyline_points[i].dim text = "d: " + formatted_value - screen_coords[f"distance_{i}"] = (dim_text_coords, text) - # --- Show angles for polyline/poly area --- - if self.measure_type != "SINGLE" and len(self.polyline_points) > 2: - for i in range(len(self.polyline_points)): - if i == 0 or i == len(self.polyline_points) - 1: - continue - angle_text_pos = Vector(self.polyline_points[i].position) - angle_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, angle_text_pos) - if angle_text_coords: - text = "a: " + self.polyline_points[i+1].angle - screen_coords[f"angle_{i}"] = (angle_text_coords, text) + screen_coords[f"distance_{i}"] = (Vector(dim_text_coords), text) + if i == 1: + continue + angle_text_pos = Vector(self.polyline_points[i - 1].position) + angle_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, angle_text_pos) + if angle_text_coords: + text = "a: " + self.polyline_points[i].angle + screen_coords[f"angle_{i}"] = (Vector(angle_text_coords), text) - # --- Show XYZ axis values for single measurement --- if self.measure_type == "SINGLE": axis_line, axis_line_center = self.calculate_measurement_x_y_and_z(context) - if axis_line and axis_line_center: - for i, dim_text_pos in enumerate(axis_line_center): - dim_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, dim_text_pos) - if dim_text_coords: - value = round((axis_line[i][1] - axis_line[i][0]).length, 4) - direction = axis_line[i][1] - axis_line[i][0] - if (i == 0 and direction.x < 0) or (i == 1 and direction.y < 0) or (i == 2 and direction.z < 0): - value = -value - prefix = "xyz"[i] - formatted_value = tool.Polyline.format_input_ui_units(value) - text = f"{prefix}: {formatted_value}" - screen_coords[f"xyz_{i}"] = (dim_text_coords, text) + for i, dim_text_pos in enumerate(axis_line_center): + dim_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, dim_text_pos) + if dim_text_coords: + value = round((axis_line[i][1] - axis_line[i][0]).length, 4) + direction = axis_line[i][1] - axis_line[i][0] + if (i == 0 and direction.x < 0) or (i == 1 and direction.y < 0) or (i == 2 and direction.z < 0): + value = -value + prefix = "xyz"[i] + formatted_value = tool.Polyline.format_input_ui_units(value) + text = f"{prefix}: {formatted_value}" + screen_coords[f"xyz_{i}"] = (Vector(dim_text_coords), text) - #Area and Length text + # Area and Length text polyline_verts = [Vector((p.x, p.y, p.z)) for p in self.polyline_points] # Area if self.measure_type == "POLY_AREA" and self.polyline_data.area: - if len(polyline_verts) >= 3: - center = sum(polyline_verts, Vector()) / len(polyline_verts) - if polyline_verts[0] == polyline_verts[-1]: - center = sum(polyline_verts[:-1], Vector()) / len(polyline_verts[:-1]) - area_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, center) - if area_text_coords: - value = self.polyline_data.area - text = f"area: {value}" - screen_coords["area"] = (area_text_coords, text) + if len(polyline_verts) < 3: + blf.disable(self.font_id, blf.SHADOW) + return + center = sum(polyline_verts, Vector()) / len(polyline_verts) + if polyline_verts[0] == polyline_verts[-1]: + center = sum(polyline_verts[:-1], Vector()) / len(polyline_verts[:-1]) + area_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, center) + if area_text_coords: + value = self.polyline_data.area + text = f"area: {value}" + text_length = blf.dimensions(self.font_id, text) + area_text_coords = list(area_text_coords) + area_text_coords[0] -= text_length[0] / 2 + screen_coords["area"] = (Vector(area_text_coords), text) # Length if self.measure_type in {"POLYLINE", "POLY_AREA"}: - if len(polyline_verts) >= 3: - total_length_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, polyline_verts[-1]) - if total_length_text_coords: - value = self.polyline_data.total_length - text = f"length: {value}" - screen_coords["length"] = (total_length_text_coords, text) + if len(polyline_verts) < 3: + blf.disable(self.font_id, blf.SHADOW) + return + total_length_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, polyline_verts[-1]) + if total_length_text_coords: + value = self.polyline_data.total_length + text = f"length: {value}" + screen_coords["length"] = (Vector(total_length_text_coords), text) self.adjust_overlapping_labels(screen_coords) From 4c47eb573aa66e617696fcc5bf311be8a0d45722 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 22 Jan 2026 11:18:35 +1100 Subject: [PATCH 33/37] Improve IDS static HTML reporter to report list of prohibited entities (they aren't passes or fails, just prohibited applicability) --- src/ifctester/ifctester/reporter.py | 24 +++++++++++ src/ifctester/ifctester/templates/report.html | 40 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/ifctester/ifctester/reporter.py b/src/ifctester/ifctester/reporter.py index 84684a10a2..e99e13ea8f 100644 --- a/src/ifctester/ifctester/reporter.py +++ b/src/ifctester/ifctester/reporter.py @@ -85,6 +85,7 @@ class ResultsSpecification(TypedDict): total_applicable: int total_applicable_pass: int total_applicable_fail: int + applicable_entities: list[ResultsEntity] percent_applicable_pass: ResultsPercent total_checks: int total_checks_pass: int @@ -383,6 +384,7 @@ class Json(Reporter): total_applicable=total_applicable, total_applicable_pass=total_applicable_pass, total_applicable_fail=total_applicable - total_applicable_pass, + applicable_entities=self.report_applicable_entities(specification), percent_applicable_pass=percent_applicable_pass, total_checks=total_checks, total_checks_pass=total_checks_pass, @@ -393,6 +395,24 @@ class Json(Reporter): requirements=requirements, ) + def report_applicable_entities(self, specification: Specification) -> list[ResultsEntity]: + return [ + ResultsEntity( + { + "element": e, + "element_type": ifcopenshell.util.element.get_type(e), + "class": e.is_a(), + "predefined_type": ifcopenshell.util.element.get_predefined_type(e), + "name": getattr(e, "Name", None), + "description": getattr(e, "Description", None), + "id": e.id(), + "global_id": getattr(e, "GlobalId", None), + "tag": getattr(e, "Tag", None), + } + ) + for e in specification.applicable_entities + ] + def report_passed_entities(self, requirement: Facet) -> list[ResultsEntity]: return [ ResultsEntity( @@ -459,6 +479,10 @@ class Html(Json): spec["is_prohibited"] = spec["cardinality"] == "prohibited" spec["cardinality"] = spec["cardinality"].capitalize() spec["has_requirements"] = bool(spec["requirements"]) + total_applicable_entities = len(spec["applicable_entities"]) + spec["applicable_entities"] = self.limit_entities(spec["applicable_entities"]) + spec["has_omitted_applicable"] = total_applicable_entities > self.entity_limit + spec["total_omitted_applicable"] = total_applicable_entities - self.entity_limit for requirement in spec["requirements"]: total_passed_entities = len(requirement["passed_entities"]) total_failed_entities = len(requirement["failed_entities"]) diff --git a/src/ifctester/ifctester/templates/report.html b/src/ifctester/ifctester/templates/report.html index 456e90e5f4..51beb5c0f5 100644 --- a/src/ifctester/ifctester/templates/report.html +++ b/src/ifctester/ifctester/templates/report.html @@ -152,7 +152,6 @@

Requirements

- {{/has_requirements}}
    {{#requirements}}
  1. @@ -252,6 +251,45 @@
  2. {{/requirements}}
+ {{/has_requirements}} + {{#is_prohibited}} + {{#total_applicable}} + + + + + + + + + + + + + {{#applicable_entities}} + + + + + + + + + {{#extra_of_type}} + + + + {{/extra_of_type}} + {{/applicable_entities}} + {{#has_omitted_applicable}} + + + + {{/has_omitted_applicable}} + +
ClassPredefinedTypeNameDescriptionGlobalIdTag
{{class}}{{predefined_type}}{{name}}{{description}}{{global_id}}{{tag}}
... {{extra_of_type}} more of the same element type ({{type_name}} with Tag {{type_tag}} and GlobalId {{type_global_id}}) not shown ...
... {{total_omitted_applicable}} more failing elements not shown out of {{total_applicable}} total ...
+ {{/total_applicable}} + {{/is_prohibited}} {{/specifications}} From c2cc6f80be80a4251a3e9a5c00209f8c51e7148a Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 22 Jan 2026 14:36:46 +1100 Subject: [PATCH 34/37] IfcTester webapp now shows prohibited more clearly - is_skipped is calculated upstream instead of reimplementing logic - the viewer now shows cardinality (not just in edit mode) to make it clear what the requirements are - prohibited specs are not allowed to have any requirements, so that section is hidden - failed prohibited specs now shows list of failed (applicable) entities --- src/ifctester/ifctester/reporter.py | 4 +- .../webapp/src/pages/Home/IdsViewer.svelte | 140 +++++++++++++++--- 2 files changed, 124 insertions(+), 20 deletions(-) diff --git a/src/ifctester/ifctester/reporter.py b/src/ifctester/ifctester/reporter.py index e99e13ea8f..38b3ba8f5b 100644 --- a/src/ifctester/ifctester/reporter.py +++ b/src/ifctester/ifctester/reporter.py @@ -81,6 +81,7 @@ class ResultsSpecification(TypedDict): description: str instructions: str status: bool + is_skipped: bool is_ifc_version: bool total_applicable: int total_applicable_pass: int @@ -380,6 +381,7 @@ class Json(Reporter): description=specification.description, instructions=specification.instructions, status=specification.status, + is_skipped=cardinality == "optional" and total_checks == 0, is_ifc_version=specification.is_ifc_version, total_applicable=total_applicable, total_applicable_pass=total_applicable_pass, @@ -474,8 +476,6 @@ class Html(Json): def report(self) -> None: super().report() for spec in self.results["specifications"]: - if spec["cardinality"] == "optional" and spec["total_checks"] == 0: - spec["is_skipped"] = True spec["is_prohibited"] = spec["cardinality"] == "prohibited" spec["cardinality"] = spec["cardinality"].capitalize() spec["has_requirements"] = bool(spec["requirements"]) diff --git a/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte b/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte index e9645e0984..2fda5f0c2c 100644 --- a/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte +++ b/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte @@ -50,13 +50,7 @@ function getSpecificationStatus(specIndex, auditData) { const spec = auditData.specifications[specIndex]; if (!spec) return null; - - // If no applicable elements and no checks, and it passed, it's actually skipped - if (spec.total_applicable === 0 && spec.total_checks === 0 && spec.status === true) { - return 'skipped'; - } - - return spec.status; + return spec.is_skipped ? 'skipped' : spec.status; } function getSpecificationStats(specIndex, auditData) { @@ -79,11 +73,13 @@ const status = getSpecificationStatus(specIndex, auditData); if (status === 'skipped') { - return "Skipped because no applicable entities were found and the cardinality is OPTIONAL or PROHIBITED"; + return "Skipped because no applicable entities were found and the cardinality is OPTIONAL"; } if (status === false) { // Failed - if (spec.total_applicable === 0) { + if (spec.cardinality === 'prohibited') { + return `Failed because ${spec.total_applicable} prohibited entities were found`; + } else if (spec.total_applicable === 0) { return "Failed because no applicable entities were found but the cardinality is REQUIRED"; } else { const failedChecks = spec.total_checks - spec.total_checks_pass; @@ -240,19 +236,30 @@ {#if "@description" in spec}

{spec["@description"]}

{/if} +
+ {#if spec.applicability["@minOccurs"] === 1 && spec.applicability["@maxOccurs"] === 'unbounded'} + Required + {/if} + {#if spec.applicability["@minOccurs"] === 0 && spec.applicability["@maxOccurs"] === 'unbounded'} + Optional + {/if} + {#if spec.applicability["@minOccurs"] === 0 && spec.applicability["@maxOccurs"] === 0} + Prohibited + {/if} + {#if auditReport} + {@const stats = getSpecificationStats(index, auditReport.data)} + {@const status = getSpecificationStatus(index, auditReport.data)} + {#if stats && spec.applicability["@maxOccurs"] !== 0 && status !== 'skipped'} + Checks: {stats.checksPassed}/{stats.checksTotal} + Requirements: {stats.requirementsPassed}/{stats.requirements} + {/if} + {/if} +
{#if auditReport} {@const reason = getSpecificationReason(index, auditReport.data)} {#if reason}

{reason}

{/if} - {@const stats = getSpecificationStats(index, auditReport.data)} - {@const status = getSpecificationStatus(index, auditReport.data)} - {#if stats && status !== 'skipped'} -
- Checks: {stats.checksPassed}/{stats.checksTotal} - Requirements: {stats.requirementsPassed}/{stats.requirements} -
- {/if} {/if}
@@ -292,9 +299,105 @@ {/if} {/each}
+ + {#if auditReport} + {@const status = getSpecificationStatus(index, auditReport.data)} + {#if ! status && spec.applicability["@maxOccurs"] == 0} + {@const specReport = auditReport.data.specifications[index]} +
+ {#if specReport.applicable_entities && specReport.applicable_entities.length > 0} +
+

Failed Elements ({specReport.applicable_entities.length})

+
+ + + + + + + + + + + + + + + {#each specReport.applicable_entities.slice(0, 10) as entity} + + + + + + + + + + {/each} + {#if specReport.applicable_entities.length > 10} + + + + {/if} + +
ClassPredefinedTypeNameDescriptionWarningGlobalIdTag
{entity.class}{entity.predefined_type || '-'} + + +
{entity.name || '-'}
+
+ +

{entity.name || '-'}

+
+
+
+ + +
{entity.description || '-'}
+
+ +

{entity.description || '-'}

+
+
+
+ + +
{entity.reason || '-'}
+
+ +

{entity.reason || '-'}

+
+
+
+ + +
{entity.global_id || '-'}
+
+ +

{entity.global_id || '-'}

+
+
+
+ + +
{entity.tag || '-'}
+
+ +

{entity.tag || '-'}

+
+
+
... {specReport.applicable_entities.length - 10} more failing elements not shown ...
+
+
+
+ {/if} +
+ + {/if} + {/if} + {#if Array.isArray(spec.requirements) && spec.requirements.length > 0}

Requirements

@@ -497,6 +600,7 @@ {/each}
+ {/if} {/if} @@ -1128,4 +1232,4 @@ white-space: nowrap; cursor: pointer; } - \ No newline at end of file + From 247f0c41d96ff59d58edfbdf21693417652a3e10 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 23 Jan 2026 15:32:04 +1100 Subject: [PATCH 35/37] Increase default deflection tolerance to improve performance This only results in a lower resolution for things like advanced brep geometry which isn't a biggie (I'll eat my words later) --- src/bonsai/bonsai/bim/import_ifc.py | 2 +- src/bonsai/bonsai/bim/module/project/prop.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 05bcbed01a..d8ecfd6894 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -1303,7 +1303,7 @@ class IfcImportSettings: self.should_load_geometry = True self.should_clean_mesh = False self.should_cache = True - self.deflection_tolerance = 0.001 + self.deflection_tolerance = 0.05 # Default is 0.001, but I find this to be more practical self.angular_tolerance = 0.5 self.void_limit = 30 self.style_limit = 300 diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 8983c927c9..6894392437 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -319,7 +319,7 @@ class BIMProjectProperties(PropertyGroup): ), default=False, ) - deflection_tolerance: FloatProperty(name="Deflection Tolerance", default=0.001) + deflection_tolerance: FloatProperty(name="Deflection Tolerance", default=0.05) angular_tolerance: FloatProperty(name="Angular Tolerance", default=0.5) void_limit: IntProperty( name="Void Limit", From 5dd771c5e5a2730361a0128bb77a085bf11a7e11 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 23 Jan 2026 15:51:39 +1100 Subject: [PATCH 36/37] Fix #7596. Bug where IFC2X3's workspace didn't use IfcDoorStyle and IfcWindowStyle instead --- src/bonsai/bonsai/bim/module/model/data.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/model/data.py b/src/bonsai/bonsai/bim/module/model/data.py index 2c96e42925..649b92b325 100644 --- a/src/bonsai/bonsai/bim/module/model/data.py +++ b/src/bonsai/bonsai/bim/module/model/data.py @@ -52,6 +52,11 @@ class AuthoringData: cls.is_loaded = True cls.props = tool.Model.get_model_props() cls.data["default_container"] = cls.default_container() + if tool.Ifc.get().schema == "IFC2X3": + if ifc_element_type == "IfcDoorType": + ifc_element_type = "IfcDoorStyle" + elif ifc_element_type == "IfcWindowType": + ifc_element_type = "IfcWindowStyle" cls.data["ifc_element_type"] = ifc_element_type cls.data["ifc_classes"] = cls.ifc_classes() cls.data["ifc_class_current"] = cls.ifc_class_current() From ac128e33958cb09335d5aac57873972971c38d54 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 23 Jan 2026 15:56:44 +1100 Subject: [PATCH 37/37] Close #7601. Clearer docs that there is a single mesh item. --- .../docs/ifcopenshell-python/geometry_creation.rst | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst index 65c21cafef..817630ad7d 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst @@ -340,8 +340,12 @@ In IFC, meshes may be stored as **Faceted BReps**, **Tessellations**, or # These vertices and faces represent a 2m square 1m high pyramid in SI units. # Note how they are nested lists. Each nested list represents a "mesh". There may be multiple meshes. - vertices = [[(0.,0.,0.), (0.,2.,0.), (2.,2.,0.), (2.,0.,0.), (1.,1.,1.)]] - faces = [[(0,1,2,3), (0,4,1), (1,4,2), (2,4,3), (3,4,0)]] + vertices = [ + [(0.,0.,0.), (0.,2.,0.), (2.,2.,0.), (2.,0.,0.), (1.,1.,1.)] # A single mesh + ] + faces = [ + [(0,1,2,3), (0,4,1), (1,4,2), (2,4,3), (3,4,0)] # A single mesh + ] representation = ifcopenshell.api.geometry.add_mesh_representation(model, context=body, vertices=vertices, faces=faces) .. image:: images/mesh-representation.png