From fed98451d47957f930106d39c778b7da922b3e38 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 27 Jan 2026 01:32:06 +0100 Subject: [PATCH 01/60] connect the ports when creating several conected IfcFlowSegment with polyline --- src/bonsai/bonsai/bim/module/model/profile.py | 6 ++++++ .../bonsai/bim/module/system/operator.py | 20 ++++++++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index 9271f2acc1..0a11fa191b 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -1131,8 +1131,14 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato MEPGenerator().setup_ports(profile1["obj"]) else: + connect_IfcFlowSegments = tool.Ifc.get_entity(profiles[0]["obj"]).is_a("IfcFlowSegment") for profile1, profile2 in zip(profiles[:-1], profiles[1:]): DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"]) + if connect_IfcFlowSegments: + bpy.ops.bim.mep_connect_elements( + obj1_name=profile1["obj"].name, + obj2_name=profile2["obj"].name + ) def modal(self, context, event): return IfcStore.execute_ifc_operator(self, context, event, method="MODAL") diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index 8dd6724de3..5fecd05ffb 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -317,17 +317,19 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Connect MEP Elements" bl_description = "Connects two selected elements by their closest located ports and adjusts them" bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not len(context.selected_objects) == 2: - cls.poll_message_set("Need to select 2 objects.") - return False - return True + obj1_name: bpy.props.StringProperty(name="Object 1") + obj2_name: bpy.props.StringProperty(name="Object 2") def _execute(self, context): - obj1 = context.active_object - obj2 = next(o for o in context.selected_objects if o != obj1) + if self.obj1_name and self.obj2_name: + obj1 = bpy.data.objects.get(self.obj1_name) + obj2 = bpy.data.objects.get(self.obj2_name) + else: + if not context.selected_objects or len(context.selected_objects) != 2: + self.report({"ERROR"}, "Need to select 2 objects.") + return {"CANCELLED"} + obj1 = context.active_object + obj2 = next(o for o in context.selected_objects if o != obj1) tool.Model.sync_object_ifc_position(obj1) tool.Model.sync_object_ifc_position(obj2) From 8a32d7bea0fca29699688cbed53e2eb8a0318e14 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 27 Jan 2026 18:05:19 +0100 Subject: [PATCH 02/60] Add EstablishPathDirection operator and update UI to integrate it --- .../bonsai/bim/module/system/__init__.py | 1 + .../bonsai/bim/module/system/operator.py | 50 +++++++++++++++++++ src/bonsai/bonsai/bim/module/system/ui.py | 4 +- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/system/__init__.py b/src/bonsai/bonsai/bim/module/system/__init__.py index d08046ee8f..36901100f6 100644 --- a/src/bonsai/bonsai/bim/module/system/__init__.py +++ b/src/bonsai/bonsai/bim/module/system/__init__.py @@ -37,6 +37,7 @@ classes = ( operator.EditZone, operator.EnableEditingSystem, operator.EnableEditingZone, + operator.EstablishPathDirection, operator.HidePorts, operator.LoadSystems, operator.LoadZones, diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index 5fecd05ffb..18e8d91d88 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -479,6 +479,56 @@ class CycleFlowDirection(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} +class EstablishPathDirection(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.establish_path_direction" + bl_label = "Establish Path Direction" + bl_description = "Propagates flow direction through connected flow segments with two ports" + bl_options = {"REGISTER", "UNDO"} + port_id: bpy.props.IntProperty() + + def _execute(self, context): + connected_port = tool.Ifc.get().by_id(self.port_id) + + + if not connected_port or not connected_port.is_a("IfcDistributionPort"): + self.report({"ERROR"}, "Invalid port specified.") + return {"CANCELLED"} + + direction_map = { + "SOURCE": "SINK", + "SINK": "SOURCE", + "SOURCEANDSINK": "SOURCEANDSINK", + "NOTDEFINED": "NOTDEFINED", + } + next_element = tool.System.get_port_relating_element(connected_port) + ports = tool.System.get_ports(next_element) + segments_processed = 0 + while (len(ports) == 2): + if ports[0].id() == connected_port.id(): + other_port = ports[1] + else: + other_port = ports[0] + + new_direction = direction_map.get(connected_port.FlowDirection, "NOTDEFINED") + other_port.FlowDirection = new_direction + segments_processed += 1 + + connected_port = tool.System.get_connected_port(other_port) + if not connected_port: + break + connected_port.FlowDirection = direction_map.get(other_port.FlowDirection, "NOTDEFINED") + next_element = tool.System.get_port_relating_element(connected_port) + + if not next_element.is_a("IfcFlowSegment"): + print(f"DEBUG: next_element is not IfcFlowSegment, stopping") + break + + ports = tool.System.get_ports(next_element) + + self.report({"INFO"}, f"Established path direction through {segments_processed} flow segment(s).") + 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 d621adc02b..a3d73c10f6 100644 --- a/src/bonsai/bonsai/bim/module/system/ui.py +++ b/src/bonsai/bonsai/bim/module/system/ui.py @@ -296,7 +296,7 @@ class BIM_PT_port(Panel): element = tool.Ifc.get_entity(context.active_object) row = layout.row(align=True) - cols = [row.column(align=True) for i in range(9)] + cols = [row.column(align=True) for i in range(10)] cols[3].scale_x = 1.0 cols[6].scale_x = 1.0 cols[8].scale_x = 1.33 @@ -333,12 +333,14 @@ class BIM_PT_port(Panel): else: cols[7].label(text="", icon="BLANK1") cols[8].label(text="") + cols[9].operator("bim.establish_path_direction", text="", icon="CON_FOLLOWPATH").port_id = connected_port.id() else: cols[4].label(text="", icon="BLANK1") cols[5].label(text="", icon="BLANK1") cols[6].label(text="Port is disconnected") cols[7].label(text="", icon="BLANK1") cols[8].label(text="") + cols[9].label(text="", icon="BLANK1") class BIM_PT_flow_controls(Panel): From 24a58cba405ddca878ff30d3a35ac0f285b5e7a1 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 28 Jan 2026 09:14:40 +1100 Subject: [PATCH 03/60] Remove functionality to unlink from non-IFC collections This is a cool idea, but users have all sorts of Blender collection strategies and I think it's a good idea for Bonsai code to just touch Bonsai's stuff and leave everything else. Separate functionality can be built for non-Bonsai workflows and preferrably in a more discoverable way than in individual Bonsai features. --- src/bonsai/bonsai/bim/module/spatial/operator.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 18fc35c1c9..04617cc025 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -153,16 +153,10 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): "Assign the selected objects to the container selected in Spatial Manager.\n\n" "All elements-parts of an aggregate will be skipped.\n" "To assign a container, they should be unassigned from an aggregate first.\n\n" - "This will also move objects to the container collection in the outliner.\n" - "ALT + Click to ensure objects are only linked in the container collection" + "This will also move objects to the container collection in the outliner." ) bl_options = {"REGISTER", "UNDO"} container: bpy.props.IntProperty(options={"SKIP_SAVE"}) - remove_from_other_containers: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - - def invoke(self, context, event): - self.remove_from_other_containers = event.alt - return self.execute(context) def _execute(self, context): if self.container: @@ -193,9 +187,6 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): objs.append(obj) for element_obj in objs: - if self.remove_from_other_containers: - for col in element_obj.users_collection[:]: - col.objects.unlink(element_obj) core.assign_container(tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj) aggregates_msg = "" From 35ae1e927da71bd0a116675e719cba53ad05f399 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 28 Jan 2026 17:49:15 +1100 Subject: [PATCH 04/60] Fix missing model prophet in core test bootstrap --- src/bonsai/test/core/bootstrap.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/bonsai/test/core/bootstrap.py b/src/bonsai/test/core/bootstrap.py index 3cf7589420..057ee9ffab 100644 --- a/src/bonsai/test/core/bootstrap.py +++ b/src/bonsai/test/core/bootstrap.py @@ -137,6 +137,13 @@ def misc(): prophet.verify() +@pytest.fixture +def model(): + prophet = Prophecy(bonsai.core.tool.Model) + yield prophet + prophet.verify() + + @pytest.fixture def nest(): prophet = Prophecy(bonsai.core.tool.Nest) From 79fa47fc2fa8a2e4b9d961c6c386e4ea23f2ed6d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 28 Jan 2026 17:49:31 +1100 Subject: [PATCH 05/60] Allow specifying MODULE in make test-core for convenience --- src/bonsai/Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 5b484f9942..68433466bd 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -336,7 +336,11 @@ test: .PHONY: test-core test-core: +ifndef MODULE pytest -p no:pytest-blender test/core +else + pytest -p no:pytest-blender test/core/test_${MODULE}.py +endif .PHONY: test-bim test-bim: From ff35666ad9284a9259c887c687fe3d418e793b86 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 28 Jan 2026 18:00:04 +1100 Subject: [PATCH 06/60] Reimplement feature to reassign inherited containers if you select a child element This reimplements @theoryshaw 's commit 9adbd4 but has a few upgrades: - Considers all parent / child relationships, not just aggregates - Puts business logic in core where it belongs and tool code in tool - Uses existing utils where possible like get_decomposition - Does not use name based collection checking which is fragile - Reuses tool.Collector - Makes container assignment handle the API's capability to do things in bulk instead of one by one in a loop, so it's faster - Tests --- .../bonsai/bim/module/aggregate/operator.py | 2 +- .../bonsai/bim/module/geometry/operator.py | 2 +- src/bonsai/bonsai/bim/module/model/product.py | 4 +-- .../bonsai/bim/module/spatial/operator.py | 25 ++-------------- src/bonsai/bonsai/bim/module/spatial/prop.py | 4 +-- src/bonsai/bonsai/core/spatial.py | 25 ++++++++++------ src/bonsai/bonsai/core/tool.py | 4 ++- src/bonsai/bonsai/tool/spatial.py | 26 +++++++++++++---- src/bonsai/test/core/test_spatial.py | 27 +++++++++-------- src/bonsai/test/tool/test_spatial.py | 29 ++++--------------- 10 files changed, 70 insertions(+), 78 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index be6272c3ab..e9f23afece 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -224,7 +224,7 @@ class BIM_OT_add_aggregate(bpy.types.Operator, tool.Ifc.Operator): tool.Collector, tool.Spatial, container=current_container, - element_obj=aggregate, + objs=[aggregate], ) core.assign_object(tool.Ifc, tool.Aggregate, tool.Collector, relating_obj=aggregate, related_obj=obj) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 15f3052935..fbd694d219 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -1730,7 +1730,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): tool.Collector, tool.Spatial, container=original_data[matching_group_id][index]["Container"], - element_obj=obj, + objs=[obj], ) for part in ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(obj)): tool.Collector.assign(tool.Ifc.get_object(part)) diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index 8ca5712116..012cba4889 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -470,7 +470,7 @@ class AddOccurrence(bpy.types.Operator, tool.Ifc.Operator): parent = ifcopenshell.util.element.get_container(building_element) if parent: bonsai.core.spatial.assign_container( - tool.Ifc, tool.Collector, tool.Spatial, container=parent, element_obj=obj + tool.Ifc, tool.Collector, tool.Spatial, container=parent, objs=[obj] ) # set occurrences properties for the types defined with modifiers @@ -493,7 +493,7 @@ class AddOccurrence(bpy.types.Operator, tool.Ifc.Operator): else: if self.container_obj: bonsai.core.spatial.assign_container( - tool.Ifc, tool.Collector, tool.Spatial, container=self.container, element_obj=obj + tool.Ifc, tool.Collector, tool.Spatial, container=self.container, objs=[obj] ) if props.rl_mode == "BOTTOM": obj.location.z = self.container_obj.location.z - tool.Blender.get_object_bounding_box(obj)["min_z"] diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 04617cc025..23c0c28335 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -171,28 +171,9 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): else: return - objs: list[bpy.types.Object] = [] - # In IFC element can be either contained of aggregated, - # tehrefore we skip aggregated elements here to prevent confusion. - # Can't handle it in `poll` since user might just select bunch of elements - # and try to assign a container to them - # and excluding aggregates because of the `poll` failing might get awkward. - skipped_aggregates = 0 - for obj in tool.Blender.get_selected_objects(): - if not (element := tool.Ifc.get_entity(obj)): - continue - if ifcopenshell.util.element.get_aggregate(element): - skipped_aggregates += 1 - continue - objs.append(obj) - - for element_obj in objs: - core.assign_container(tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj) - - aggregates_msg = "" - if skipped_aggregates: - aggregates_msg = f" {skipped_aggregates} aggregated elements skipped." - self.report({"INFO"}, f"{len(objs)} elements assigned.{aggregates_msg}") + core.assign_container( + tool.Ifc, tool.Collector, tool.Spatial, container=container, objs=tool.Blender.get_selected_objects() + ) class EnableEditingContainer(bpy.types.Operator): diff --git a/src/bonsai/bonsai/bim/module/spatial/prop.py b/src/bonsai/bonsai/bim/module/spatial/prop.py index 18ddc6080d..5c48566d08 100644 --- a/src/bonsai/bonsai/bim/module/spatial/prop.py +++ b/src/bonsai/bonsai/bim/module/spatial/prop.py @@ -175,8 +175,8 @@ def poll_container_obj(self: "BIMObjectSpatialProperties", container_obj: bpy.ty obj = self.id_data if ( (container := tool.Ifc.get_entity(container_obj)) - and (tool.Ifc.get_entity(obj)) - and tool.Spatial.can_contain(container, obj) + and (element := tool.Ifc.get_entity(obj)) + and tool.Spatial.can_contain(container, element) ): return True return False diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 4207d033d4..c464f84766 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -52,15 +52,22 @@ def assign_container( collector: type[tool.Collector], spatial: type[tool.Spatial], container: ifcopenshell.entity_instance, - element_obj: Optional[bpy.types.Object] = None, + objs: Optional[bpy.types.Object] = None, ) -> Union[ifcopenshell.entity_instance, None]: - if not spatial.can_contain(container, element_obj): - return - assert element_obj # Type checker. - rel = ifc.run("spatial.assign_container", products=[ifc.get_entity(element_obj)], relating_structure=container) - spatial.disable_editing(element_obj) - collector.assign(element_obj) - return rel + root_elements = set() + all_elements = set() + for obj in objs: + if not (element := ifc.get_entity(obj)): + continue + root_element = spatial.get_root_element(element) + root_elements.add(root_element) + spatial.disable_editing(obj) + all_elements.add(root_element) + all_elements.update(spatial.get_decomposition(root_element)) + if products := [e for e in root_elements if spatial.can_contain(container, root_element)]: + ifc.run("spatial.assign_container", products=products, relating_structure=container) + for element in all_elements: + collector.assign(ifc.get_object(element)) def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None: @@ -98,7 +105,7 @@ def copy_to_container( copied_obj = spatial.duplicate_object_and_data(obj) spatial.set_relative_object_matrix(copied_obj, to_container_obj, matrix) result_objs.append(spatial.run_root_copy_class(obj=copied_obj)) - spatial.run_spatial_assign_container(container=to_container, element_obj=copied_obj) + spatial.run_spatial_assign_container(container=to_container, objs=[copied_obj]) spatial.disable_editing(obj) return result_objs diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 9d78297c4e..aad79c097e 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -954,12 +954,14 @@ class Spatial: def get_object_matrix(cls, obj): pass def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass def get_selected_product_types(cls): pass + def get_root_element(cls, element): pass + def get_decomposition(cls, element): pass def get_selected_products(cls): pass def import_spatial_decomposition(cls): pass def import_spatial_element(cls, element, level_index): pass def load_contained_elements(cls): pass def run_root_copy_class(cls, obj): pass - def run_spatial_assign_container(cls, container, element_obj): pass + def run_spatial_assign_container(cls, container, objs): pass def run_spatial_import_spatial_decomposition(cls): pass def select_object(cls, obj): pass def select_products(cls, products, unhide=False): pass diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index 70a92f4cff..d38d895c22 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -74,9 +74,25 @@ class Spatial(bonsai.core.tool.Spatial): return bpy.context.scene.BIMGridProperties @classmethod - def can_contain(cls, container: ifcopenshell.entity_instance, element_obj: Union[bpy.types.Object, None]) -> bool: - if not (element := tool.Ifc.get_entity(element_obj)): - return False + def get_decomposition(cls, element: ifcopenshell.entity_instance) -> list(ifcopenshell.entity_instance): + return ifcopenshell.util.element.get_decomposition(element) + + @classmethod + def get_root_element(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + while True: + if parent := ( + ifcopenshell.util.element.get_aggregate(element) + or ifcopenshell.util.element.get_nest(element) + or ifcopenshell.util.element.get_filled_void(element) + or ifcopenshell.util.element.get_voided_element(element) + ): + element = parent + else: + break + return element + + @classmethod + def can_contain(cls, container: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance) -> bool: if tool.Ifc.get_schema() == "IFC2X3": if not container.is_a("IfcSpatialStructureElement"): return False @@ -147,10 +163,10 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def run_spatial_assign_container( - cls, container: ifcopenshell.entity_instance, element_obj: bpy.types.Object + cls, container: ifcopenshell.entity_instance, objs: list[bpy.types.Object] ) -> Union[ifcopenshell.entity_instance, None]: return bonsai.core.spatial.assign_container( - tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj + tool.Ifc, tool.Collector, tool.Spatial, container=container, objs=objs ) @classmethod diff --git a/src/bonsai/test/core/test_spatial.py b/src/bonsai/test/core/test_spatial.py index 0ae9695ea2..ddc6116fdf 100644 --- a/src/bonsai/test/core/test_spatial.py +++ b/src/bonsai/test/core/test_spatial.py @@ -38,16 +38,19 @@ class TestDereferenceStructure: class TestAssignContainer: def test_run(self, ifc, collector, spatial): - spatial.can_contain("container", "element_obj").should_be_called().will_return(True) - ifc.get_entity("element_obj").should_be_called().will_return("element") - ifc.run( - "spatial.assign_container", products=["element"], relating_structure="container" - ).should_be_called().will_return("rel") - spatial.disable_editing("element_obj").should_be_called() - collector.assign("element_obj").should_be_called() - assert ( - subject.assign_container(ifc, collector, spatial, container="container", element_obj="element_obj") == "rel" - ) + ifc.get_entity("obj").should_be_called().will_return("element") + spatial.get_root_element("element").should_be_called().will_return("aggregate") + spatial.get_decomposition("aggregate").should_be_called().will_return(["element", "element2"]) + spatial.can_contain("container", "aggregate").should_be_called().will_return(True) + ifc.run("spatial.assign_container", products=["aggregate"], relating_structure="container").should_be_called() + spatial.disable_editing("obj").should_be_called() + ifc.get_object("aggregate").should_be_called().will_return("aggregate_obj") + ifc.get_object("element").should_be_called().will_return("obj") + ifc.get_object("element2").should_be_called().will_return("obj2") + collector.assign("aggregate_obj").should_be_called() + collector.assign("obj").should_be_called() + collector.assign("obj2").should_be_called() + subject.assign_container(ifc, collector, spatial, container="container", objs=["obj"]) class TestEnableEditingContainer: @@ -82,7 +85,7 @@ class TestCopyToContainer: spatial.duplicate_object_and_data("obj").should_be_called().will_return("new_obj") spatial.set_relative_object_matrix("new_obj", "to_container_obj", "matrix").should_be_called() spatial.run_root_copy_class(obj="new_obj").should_be_called() - spatial.run_spatial_assign_container(container="to_container", element_obj="new_obj").should_be_called() + spatial.run_spatial_assign_container(container="to_container", objs=["new_obj"]).should_be_called() spatial.disable_editing("obj").should_be_called() @@ -97,7 +100,7 @@ class TestCopyToContainer: spatial.duplicate_object_and_data("obj").should_be_called().will_return("new_obj") spatial.set_relative_object_matrix("new_obj", "to_container_obj", "matrix").should_be_called() spatial.run_root_copy_class(obj="new_obj").should_be_called() - spatial.run_spatial_assign_container(container="to_container", element_obj="new_obj").should_be_called() + spatial.run_spatial_assign_container(container="to_container", objs=["new_obj"]).should_be_called() spatial.disable_editing("obj").should_be_called() diff --git a/src/bonsai/test/tool/test_spatial.py b/src/bonsai/test/tool/test_spatial.py index 299e615f2f..005f370b01 100644 --- a/src/bonsai/test/tool/test_spatial.py +++ b/src/bonsai/test/tool/test_spatial.py @@ -43,9 +43,7 @@ class TestCanContain(NewFile): structure_obj = bpy.data.objects.new("Object", None) tool.Ifc.link(structure, structure_obj) element = ifc.createIfcWall() - element_obj = bpy.data.objects.new("Object", None) - tool.Ifc.link(element, element_obj) - assert subject.can_contain(structure, element_obj) is True + assert subject.can_contain(structure, element) is True def test_a_spatial_structure_element_can_contain_an_element_ifc2x3(self): ifc = ifcopenshell.file(schema="IFC2X3") @@ -54,9 +52,7 @@ class TestCanContain(NewFile): structure_obj = bpy.data.objects.new("Object", None) tool.Ifc.link(structure, structure_obj) element = ifc.createIfcWall() - element_obj = bpy.data.objects.new("Object", None) - tool.Ifc.link(element, element_obj) - assert subject.can_contain(structure, element_obj) is True + assert subject.can_contain(structure, element) is True def test_a_spatial_zone_element_cannot_contain_an_element(self): ifc = ifcopenshell.file() @@ -65,14 +61,7 @@ class TestCanContain(NewFile): structure_obj = bpy.data.objects.new("Object", None) tool.Ifc.link(structure, structure_obj) element = ifc.createIfcWall() - element_obj = bpy.data.objects.new("Object", None) - tool.Ifc.link(element, element_obj) - assert subject.can_contain(structure, element_obj) is False - - def test_unlinked_elements_cannot_contain_anything(self): - structure_obj = bpy.data.objects.new("Object", None) - element_obj = bpy.data.objects.new("Object", None) - assert subject.can_contain(structure_obj, element_obj) is False + assert subject.can_contain(structure, element) is False def test_a_non_spatial_element_cannot_contain_anything(self): ifc = ifcopenshell.file() @@ -81,9 +70,7 @@ class TestCanContain(NewFile): structure_obj = bpy.data.objects.new("Object", None) tool.Ifc.link(structure, structure_obj) element = ifc.createIfcWall() - element_obj = bpy.data.objects.new("Object", None) - tool.Ifc.link(element, element_obj) - assert subject.can_contain(structure, element_obj) is False + assert subject.can_contain(structure, element) is False def test_a_non_element_cannot_be_contained(self): ifc = ifcopenshell.file() @@ -92,9 +79,7 @@ class TestCanContain(NewFile): structure_obj = bpy.data.objects.new("Object", None) tool.Ifc.link(structure, structure_obj) element = ifc.createIfcTask() - element_obj = bpy.data.objects.new("Object", None) - tool.Ifc.link(element, element_obj) - assert subject.can_contain(structure, element_obj) is False + assert subject.can_contain(structure, element) is False def test_other_non_elements_that_have_a_contained_in_structure_attribute_can_be_contained(self): ifc = ifcopenshell.file() @@ -103,9 +88,7 @@ class TestCanContain(NewFile): structure_obj = bpy.data.objects.new("Object", None) tool.Ifc.link(structure, structure_obj) element = ifc.createIfcGrid() - element_obj = bpy.data.objects.new("Object", None) - tool.Ifc.link(element, element_obj) - assert subject.can_contain(structure, element_obj) is True + assert subject.can_contain(structure, element) is True class TestCanReference(NewFile): From 6f78d60c9fd73da9f74729248dba1cd9c98b5d13 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 28 Jan 2026 21:22:19 +1100 Subject: [PATCH 07/60] Minor fix for tests to prevent ambiguous labeling --- src/bonsai/bonsai/bim/module/drawing/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 365ff90ee3..8c29bfbf41 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -5056,7 +5056,7 @@ class ShowCategoryHelp(bpy.types.Operator): class AddElementValueRow(bpy.types.Operator): bl_idname = "bim.add_element_value_row" - bl_label = "Add Element" + bl_label = "Add Element Value Row" bl_description = "Add a new element value row" bl_options = {"REGISTER", "UNDO"} From bcff5c7449b920c04d2ec5d0dc38b2e704b4c8f0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 28 Jan 2026 21:49:13 +1100 Subject: [PATCH 08/60] Remove unused tool --- src/bonsai/bonsai/core/tool.py | 10 +++++----- src/bonsai/bonsai/tool/unit.py | 5 ----- src/bonsai/test/tool/test_unit.py | 12 ------------ 3 files changed, 5 insertions(+), 22 deletions(-) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index aad79c097e..19395fa449 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -951,11 +951,11 @@ class Spatial: def get_active_container(cls): pass def get_container(cls, element): pass def get_decomposed_elements(cls, container, recursive): pass + def get_decomposition(cls, element): pass def get_object_matrix(cls, obj): pass def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass - def get_selected_product_types(cls): pass def get_root_element(cls, element): pass - def get_decomposition(cls, element): pass + def get_selected_product_types(cls): pass def get_selected_products(cls): pass def import_spatial_decomposition(cls): pass def import_spatial_element(cls, element, level_index): pass @@ -1125,15 +1125,15 @@ class Unit: def disable_editing_units(cls): pass def enable_editing_units(cls): pass def export_unit_attributes(cls): pass + def get_currency_name(cls): pass + def get_project_currency_unit(cls): pass def get_scene_unit_name(cls, unit_type): pass def get_scene_unit_si_prefix(cls, name): pass def import_unit_attributes(cls, unit): pass def import_units(cls): pass - def is_scene_unit_metric(cls): pass + def is_si_unit(cls, name): pass def is_unit_class(cls, unit, ifc_class): pass def set_active_unit(cls, unit): pass - def get_project_currency_unit(cls): pass - def get_currency_name(cls): pass @interface class Voider: diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index 66b56612fe..1221f28be9 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -419,11 +419,6 @@ class Unit(bonsai.core.tool.Unit): new.is_assigned = unit in assigned_units new.ifc_class = unit.is_a() - @classmethod - def is_scene_unit_metric(cls) -> bool: - assert bpy.context.scene - return bpy.context.scene.unit_settings.system in ["METRIC", "NONE"] - @classmethod def is_unit_class(cls, unit: ifcopenshell.entity_instance, ifc_class: str) -> bool: return unit.is_a(ifc_class) diff --git a/src/bonsai/test/tool/test_unit.py b/src/bonsai/test/tool/test_unit.py index df3acc1b3e..392650da71 100644 --- a/src/bonsai/test/tool/test_unit.py +++ b/src/bonsai/test/tool/test_unit.py @@ -434,18 +434,6 @@ class TestImportUnits(NewFile): assert second_prop.ifc_class == "IfcSIUnit" -class TestIsSceneUnitMetric(NewFile): - def test_run(self): - assert bpy.context.scene - props = bpy.context.scene.unit_settings - props.system = "METRIC" - assert subject.is_scene_unit_metric() is True - props.system = "IMPERIAL" - assert subject.is_scene_unit_metric() is False - props.system = "NONE" - assert subject.is_scene_unit_metric() is True - - class TestIsUnitClass: def test_run(self): ifc = ifcopenshell.file() From 53e5f847302f32cdbe681a6ccfa9870dd15a116e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 28 Jan 2026 21:49:25 +1100 Subject: [PATCH 09/60] Fix failing core unit tests --- src/bonsai/test/core/test_unit.py | 168 +++++++++++++----------------- 1 file changed, 70 insertions(+), 98 deletions(-) diff --git a/src/bonsai/test/core/test_unit.py b/src/bonsai/test/core/test_unit.py index a7d73c755f..1c328fe84c 100644 --- a/src/bonsai/test/core/test_unit.py +++ b/src/bonsai/test/core/test_unit.py @@ -22,119 +22,91 @@ from test.core.bootstrap import ifc, unit class TestAssignSceneUnits: def test_creating_and_assigning_metric_units(self, ifc, unit): - unit.is_scene_unit_metric().should_be_called().will_return(True) - unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("prefix") - unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("prefix") - unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("prefix") - unit.add_mass_and_time_units().should_be_called().will_return(True) - unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("KILO") - unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return(None) - - ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="prefix").should_be_called().will_return( + unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name") + unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("area_name") + unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("volume_name") + unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("mass_name") + unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("time_name") + unit.is_si_unit("length_name").should_be_called().will_return(True) + unit.is_si_unit("area_name").should_be_called().will_return(True) + unit.is_si_unit("volume_name").should_be_called().will_return(True) + unit.is_si_unit("mass_name").should_be_called().will_return(True) + unit.is_si_unit("time_name").should_be_called().will_return(True) + unit.get_scene_unit_si_prefix("length_name").should_be_called().will_return("length_prefix") + unit.get_scene_unit_si_prefix("area_name").should_be_called().will_return("area_prefix") + unit.get_scene_unit_si_prefix("volume_name").should_be_called().will_return("volume_prefix") + unit.get_scene_unit_si_prefix("mass_name").should_be_called().will_return("mass_prefix") + unit.get_scene_unit_si_prefix("time_name").should_be_called().will_return("time_prefix") + ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="length_prefix").should_be_called().will_return( "lengthunit" ) - ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="prefix").should_be_called().will_return("areaunit") - ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="prefix").should_be_called().will_return( + ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="area_prefix").should_be_called().will_return( + "areaunit" + ) + ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="volume_prefix").should_be_called().will_return( "volumeunit" ) - ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="KILO").should_be_called().will_return("massunit") - ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit") - ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - + ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="mass_prefix").should_be_called().will_return( + "massunit" + ) + ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix="time_prefix").should_be_called().will_return( + "timeunit" + ) ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit"] ).should_be_called() subject.assign_scene_units(ifc, unit) - def test_creating_and_assigning_metric_units_without_mass_and_time(self, ifc, unit): - unit.is_scene_unit_metric().should_be_called().will_return(True) - unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("CENTI") - unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("CENTI") - unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("CENTI") - unit.add_mass_and_time_units().should_be_called().will_return(False) - ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="CENTI").should_be_called().will_return("lengthunit") - ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="CENTI").should_be_called().will_return("areaunit") - ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="CENTI").should_be_called().will_return("volumeunit") - ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called() + def test_creating_and_assigning_only_specified_units(self, ifc, unit): + unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name") + unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return(None) + unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return(None) + unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return(None) + unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return(None) + unit.is_si_unit("length_name").should_be_called().will_return(True) + unit.get_scene_unit_si_prefix("length_name").should_be_called().will_return("length_prefix") + ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="length_prefix").should_be_called().will_return( + "lengthunit" + ) + ifc.run("unit.assign_unit", units=["lengthunit"]).should_be_called() subject.assign_scene_units(ifc, unit) def test_creating_and_assigning_imperial_units(self, ifc, unit): - unit.is_scene_unit_metric().should_be_called().will_return(False) - unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("foot") - unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square foot") - unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic foot") - unit.add_mass_and_time_units().should_be_called().will_return(True) - unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("pound") - unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("SECOND") - - ifc.run("unit.add_conversion_based_unit", name="foot").should_be_called().will_return("lengthunit") - ifc.run("unit.add_conversion_based_unit", name="square foot").should_be_called().will_return("areaunit") - ifc.run("unit.add_conversion_based_unit", name="cubic foot").should_be_called().will_return("volumeunit") - ifc.run("unit.add_conversion_based_unit", name="pound").should_be_called().will_return("massunit") - ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit") - ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - + unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name") + unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("area_name") + unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("volume_name") + unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("mass_name") + unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("time_name") + unit.is_si_unit("length_name").should_be_called().will_return(False) + unit.is_si_unit("area_name").should_be_called().will_return(False) + unit.is_si_unit("volume_name").should_be_called().will_return(False) + unit.is_si_unit("mass_name").should_be_called().will_return(False) + unit.is_si_unit("time_name").should_be_called().will_return(False) + ifc.run("unit.add_conversion_based_unit", name="length_name").should_be_called().will_return("lengthunit") + ifc.run("unit.add_conversion_based_unit", name="area_name").should_be_called().will_return("areaunit") + ifc.run("unit.add_conversion_based_unit", name="volume_name").should_be_called().will_return("volumeunit") + ifc.run("unit.add_conversion_based_unit", name="mass_name").should_be_called().will_return("massunit") + ifc.run("unit.add_conversion_based_unit", name="time_name").should_be_called().will_return("timeunit") ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit"] ).should_be_called() subject.assign_scene_units(ifc, unit) - def test_creating_and_assigning_imperial_units_without_mass_and_time(self, ifc, unit): - unit.is_scene_unit_metric().should_be_called().will_return(False) - unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("yard") - unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square yard") - unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic yard") - unit.add_mass_and_time_units().should_be_called().will_return(False) - ifc.run("unit.add_conversion_based_unit", name="yard").should_be_called().will_return("lengthunit") - ifc.run("unit.add_conversion_based_unit", name="square yard").should_be_called().will_return("areaunit") - ifc.run("unit.add_conversion_based_unit", name="cubic yard").should_be_called().will_return("volumeunit") - ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called() - subject.assign_scene_units(ifc, unit) - - def test_creating_metric_units_with_conversion_based_mass_and_time(self, ifc, unit): - unit.is_scene_unit_metric().should_be_called().will_return(True) - unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("MILLI") - unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return(None) - unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return(None) - unit.add_mass_and_time_units().should_be_called().will_return(True) - unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("CONVERSION") - unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("tonne") - unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return("CONVERSION") - unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("minute") - - ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="MILLI").should_be_called().will_return("lengthunit") - ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix=None).should_be_called().will_return("areaunit") - ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix=None).should_be_called().will_return("volumeunit") - ifc.run("unit.add_conversion_based_unit", name="tonne").should_be_called().will_return("massunit") - ifc.run("unit.add_conversion_based_unit", name="minute").should_be_called().will_return("timeunit") - ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - - ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] - ).should_be_called() - subject.assign_scene_units(ifc, unit) - - def test_creating_imperial_units_with_conversion_based_units(self, ifc, unit): - unit.is_scene_unit_metric().should_be_called().will_return(False) - unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("inch") - unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square inch") - unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic inch") - unit.add_mass_and_time_units().should_be_called().will_return(True) - unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("ounce") - unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("hour") - - ifc.run("unit.add_conversion_based_unit", name="inch").should_be_called().will_return("lengthunit") - ifc.run("unit.add_conversion_based_unit", name="square inch").should_be_called().will_return("areaunit") - ifc.run("unit.add_conversion_based_unit", name="cubic inch").should_be_called().will_return("volumeunit") - ifc.run("unit.add_conversion_based_unit", name="ounce").should_be_called().will_return("massunit") - ifc.run("unit.add_conversion_based_unit", name="hour").should_be_called().will_return("timeunit") - ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - - ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] - ).should_be_called() + def test_creating_both_metric_and_imperial_units(self, ifc, unit): + # I know British doctors measure with stones so... + unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name") + unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("area_name") + unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return(None) + unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return(None) + unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return(None) + unit.is_si_unit("length_name").should_be_called().will_return(True) + unit.is_si_unit("area_name").should_be_called().will_return(False) + unit.get_scene_unit_si_prefix("length_name").should_be_called().will_return("length_prefix") + ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="length_prefix").should_be_called().will_return( + "lengthunit" + ) + ifc.run("unit.add_conversion_based_unit", name="area_name").should_be_called().will_return("areaunit") + ifc.run("unit.assign_unit", units=["lengthunit", "areaunit"]).should_be_called() subject.assign_scene_units(ifc, unit) From e76c2a70e6c729f7424f2f3be07bcbcc1b15d2d4 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 28 Jan 2026 23:18:57 +1100 Subject: [PATCH 10/60] Add tests for aggregate containment changing --- src/bonsai/test/bim/feature/spatial.feature | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/bonsai/test/bim/feature/spatial.feature b/src/bonsai/test/bim/feature/spatial.feature index 2b3020ea23..3ed7a80b94 100644 --- a/src/bonsai/test/bim/feature/spatial.feature +++ b/src/bonsai/test/bim/feature/spatial.feature @@ -74,6 +74,50 @@ Scenario: Assign container When I click "CHECKMARK" Then the object "IfcWall/Cube" is in the collection "IfcSite/My Site" +Scenario: Assign container - assign an aggregate which also affects children + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I look at the "Class" panel + And I set the "Products" property to "IfcElement" + And I set the "Class" property to "IfcWall" + And I click "Assign IFC Class" + And the object "IfcWall/Cube" is selected + When I press "bim.add_aggregate" + Then the object "IfcElementAssembly/Default_Name" exists + And the object "IfcElementAssembly/Default_Name" is contained in object "IfcBuildingStorey/My Storey" + When I look at the "Spatial Decomposition" panel + And I select the "My Site" item in the "BIM_UL_containers_manager" list + And I click "Set Default" + And the object "IfcWall/Cube" is selected + And I look at the "Spatial Container" panel + And I click "GREASEPENCIL" + And I click "CHECKMARK" + Then the object "IfcWall/Cube" is in the collection "IfcSite/My Site" + And the object "IfcElementAssembly/Default_Name" is in the collection "IfcSite/My Site" + +Scenario: Assign container - assign a child which also affects parents + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I look at the "Class" panel + And I set the "Products" property to "IfcElement" + And I set the "Class" property to "IfcWall" + And I click "Assign IFC Class" + And the object "IfcWall/Cube" is selected + When I press "bim.add_aggregate" + Then the object "IfcElementAssembly/Default_Name" exists + And the object "IfcElementAssembly/Default_Name" is contained in object "IfcBuildingStorey/My Storey" + When I look at the "Spatial Decomposition" panel + And I select the "My Site" item in the "BIM_UL_containers_manager" list + And I click "Set Default" + And the object "IfcElementAssembly/Default_Name" is selected + And I look at the "Spatial Container" panel + And I click "GREASEPENCIL" + And I click "CHECKMARK" + Then the object "IfcWall/Cube" is in the collection "IfcSite/My Site" + And the object "IfcElementAssembly/Default_Name" is in the collection "IfcSite/My Site" + Scenario: Copy to container Given an empty IFC project And I add a cube From 6cfb6d74fee3dbcaf4462fed2f6039b9a4afbb20 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 28 Jan 2026 23:25:35 +1100 Subject: [PATCH 11/60] Revert "fix #7559: DirectionSense works again." This reverts commit d79524b087fb7e4099584f4cbebe3e8e8eb46457. --- src/bonsai/bonsai/bim/module/model/slab.py | 88 +++++++++++----- src/bonsai/bonsai/tool/loader.py | 111 ++++----------------- 2 files changed, 81 insertions(+), 118 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 509d24a60c..caf389165b 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -231,17 +231,16 @@ class DumbSlabPlaner: for inverse in tool.Ifc.get().get_inverse(layer_set): if not inverse.is_a("IfcMaterialLayerSetUsage") or inverse.LayerSetDirection != "AXIS3": continue - if tool.Ifc.get().schema == "IFC2X3": for rel in tool.Ifc.get().get_inverse(inverse): if not rel.is_a("IfcRelAssociatesMaterial"): continue for element in rel.RelatedObjects: - self.change_thickness(element, total_thickness, preserve_offset=True) + self.change_thickness(element, total_thickness) else: for rel in inverse.AssociatedTo: for element in rel.RelatedObjects: - self.change_thickness(element, total_thickness, preserve_offset=True) + self.change_thickness(element, total_thickness) def regenerate_from_occurence(self, element, material_set_usage): self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) @@ -251,12 +250,9 @@ class DumbSlabPlaner: return self.change_thickness(element, total_thickness) - def change_thickness( - self, element: ifcopenshell.entity_instance, thickness: float, preserve_offset: bool = False - ) -> None: + def change_thickness(self, element: ifcopenshell.entity_instance, thickness: float) -> None: if tool.Model.get_usage_type(element) != "LAYER3": return - layer_params = tool.Model.get_material_layer_parameters(element) ifc_file = tool.Ifc.get() body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") @@ -282,40 +278,79 @@ class DumbSlabPlaner: cos_angle = direction_ratios.normalized().dot(Vector((0, 0, 1))) extrusion_angle = acos(min(max(cos_angle, -1), 1)) - # Only apply 1/cos factor when there's actual extrusion slope + # FIX: Only apply 1/cos factor when there's actual extrusion slope if extrusion_angle > 1e-6: perpendicular_depth = thickness * abs(1 / cos(extrusion_angle)) - perpendicular_offset = layer_offset * abs(1 / cos(extrusion_angle)) + perpendicular_offset = layer_offset * abs(1 / cos(extrusion_angle)) / self.unit_scale else: perpendicular_depth = thickness - perpendicular_offset = layer_offset + perpendicular_offset = layer_offset / self.unit_scale + # Check if direction sense needs to be applied + # This should only happen if explicitly requested, not automatically + if layer_params.get("apply_direction_sense", False): + # Store current direction before potential change + old_direction = direction_ratios.copy() + + # Apply direction sense logic + existing_x_angle = extrusion_angle + if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or ( + abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0 + ): + if layer_params["direction_sense"] == "NEGATIVE": + direction_ratios *= -1 + elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or ( + abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0 + ): + offset_direction = direction_ratios.copy() * -1 + if layer_params["direction_sense"] == "POSITIVE": + direction_ratios *= -1 + + # If direction changed, update extrusion with rotation compensation + if (direction_ratios.normalized() - old_direction.normalized()).length > 1e-6: + update_extrusion_direction(element, tuple(direction_ratios), obj) + # After updating direction, get the updated extrusion + extrusion = tool.Model.get_extrusion(representation) + + # Update depth extrusion.Depth = perpendicular_depth # Update position ifc_position = extrusion.Position - if direction_ratios.length > 0: offset_vector = direction_ratios.normalized() * perpendicular_offset position = offset_vector material = ifcopenshell.util.element.get_material(element) if material and material.is_a("IfcMaterialLayerSetUsage"): - # Only set offset if not preserving it (preserves independent offsets per instance) - if not preserve_offset: - material.OffsetFromReferenceLine = position.z + material.OffsetFromReferenceLine = position.z if ifc_position: ifc_position.Location.Coordinates = position else: tool.Model.add_extrusion_position(extrusion, position) - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=representation, - ) + else: + props = tool.Model.get_model_props() + x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle + new_rep = ifcopenshell.api.geometry.add_slab_representation( + tool.Ifc.get(), + context=body_context, + depth=thickness * self.unit_scale, + x_angle=x_angle, + ) + for inverse in tool.Ifc.get().get_inverse(representation): + ifcopenshell.util.element.replace_attribute(inverse, representation, new_rep) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=new_rep, + ) + bonsai.core.geometry.remove_representation( + tool.Ifc, tool.Geometry, obj=obj, representation=representation + ) + return else: props = tool.Model.get_model_props() x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle @@ -328,12 +363,13 @@ class DumbSlabPlaner: ifcopenshell.api.geometry.assign_representation( tool.Ifc.get(), product=element, representation=representation ) - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=representation, - ) + + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + ) def update_extrusion_direction( element: ifcopenshell.entity_instance, new_direction_ratios: tuple, obj: bpy.types.Object = None diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index 8fdce1da4c..ebc6722bee 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -1024,7 +1024,7 @@ class Loader(bonsai.core.tool.Loader): elif material.is_a("IfcMaterialLayerSetUsage"): usage = material layer_set = material.ForLayerSet - offset = usage.OffsetFromReferenceLine + offset = usage.OffsetFromReferenceLine * cls.unit_scale sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1 elif material.is_a("IfcMaterialLayerSet"): usage = None @@ -1037,17 +1037,11 @@ class Loader(bonsai.core.tool.Loader): if len(layer_set.MaterialLayers) == 1: return mesh - # Get mesh bounds - if len(mesh.vertices) > 0: - z_coords = [v.co.z for v in mesh.vertices] - mesh_z_min = min(z_coords) - mesh_z_max = max(z_coords) - bm = bmesh.new() bm.from_mesh(mesh) prev_co = None - advance_direction = None + advance_direction = None # Will store direction to advance planes if not usage: sense_factor = 1 @@ -1055,7 +1049,9 @@ class Loader(bonsai.core.tool.Loader): co = Vector((0.0, 0.0, offset)) advance_direction = no elif usage.LayerSetDirection == "AXIS2": - # Get local extrusion direction + co = Vector((0.0, offset, 0.0)) + + # Get LOCAL extrusion direction local_extrusion = Vector([0.0, 0.0, 1.0]) if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): for item in ifcopenshell.util.representation.resolve_representation(body).Items: @@ -1067,58 +1063,17 @@ class Loader(bonsai.core.tool.Loader): # Thickness direction: perpendicular to extrusion and length thickness_dir = local_extrusion.cross(Vector([1.0, 0.0, 0.0])).normalized() + + # Ensure it points in POSITIVE Y (through wall thickness, not backwards) if thickness_dir.y < 0: thickness_dir = -thickness_dir no = thickness_dir - - # Find start point by projecting vertices onto thickness direction - if len(mesh.vertices) > 0: - projections = [Vector(v.co).dot(no) for v in mesh.vertices] - min_proj = min(projections) - max_proj = max(projections) - - centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices) - centroid_proj = centroid.dot(no) - - if sense_factor == 1: - start_proj = min_proj - else: - start_proj = max_proj - - offset_dist = start_proj - centroid_proj - co = centroid + no * offset_dist - - actual_mesh_height = max_proj - min_proj - else: - co = Vector((0.0, 0.0, 0.0)) - advance_direction = thickness_dir elif usage.LayerSetDirection == "AXIS3": - # AXIS3 layers go through slab thickness (local Z) + co = Vector((0.0, 0.0, offset)) + no = cls.get_extrusion_vector(element).normalized() no = Vector([0.0, 0.0, 1.0]) - - # Find start point by projecting vertices onto Z direction - if len(mesh.vertices) > 0: - projections = [Vector(v.co).dot(no) for v in mesh.vertices] - min_proj = min(projections) - max_proj = max(projections) - - centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices) - centroid_proj = centroid.dot(no) - - if sense_factor == 1: - start_proj = min_proj - else: - start_proj = max_proj - - offset = start_proj - centroid_proj - co = centroid + no * offset - - actual_mesh_height = max_proj - min_proj - else: - co = Vector((0.0, 0.0, 0.0)) - advance_direction = no elif usage.LayerSetDirection == "AXIS1": co = Vector((0.0, 0.0, offset)) @@ -1126,27 +1081,10 @@ class Loader(bonsai.core.tool.Loader): no = Vector([1.0, 0.0, 0.0]) advance_direction = no - # Apply DirectionSense - if usage and usage.LayerSetDirection == "AXIS2": - if sense_factor == -1: - advance_direction = -advance_direction - test_normal = -no - else: - test_normal = no - elif usage and usage.LayerSetDirection == "AXIS1": - no = no * sense_factor - advance_direction = advance_direction * sense_factor - test_normal = no - elif usage and usage.LayerSetDirection == "AXIS3": - if sense_factor == -1: - advance_direction = -advance_direction - test_normal = -no - else: - test_normal = no - else: - test_normal = no + no *= sense_factor + advance_direction *= sense_factor - # Cache material styles + # Cache this body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") styles = {} has_layer_styles = False @@ -1154,23 +1092,12 @@ class Loader(bonsai.core.tool.Loader): if style := tool.Ifc.get_entity(material): styles[style] = i - layer_list = list(enumerate(layer_set.MaterialLayers)) - - # Calculate scale factor - total_layer_thickness = sum(layer.LayerThickness for _, layer in layer_list) - - if "actual_mesh_height" not in locals(): - actual_mesh_height = mesh_z_max - mesh_z_min if len(mesh.vertices) > 0 else total_layer_thickness - - thickness_scale = actual_mesh_height / total_layer_thickness if total_layer_thickness > 0 else 1.0 - last_i = len(layer_set.MaterialLayers) - 1 - - for idx, (original_i, layer) in enumerate(layer_list): - if idx != last_i: + for i, layer in enumerate(layer_set.MaterialLayers): + if i != last_i: prev_co = co.copy() - advance_vector = advance_direction * layer.LayerThickness * thickness_scale - co += advance_vector + # Use advance_direction (not no) to move planes! + co += advance_direction * layer.LayerThickness * cls.unit_scale bisect_geom = bmesh.ops.bisect_plane( bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no @@ -1183,18 +1110,18 @@ class Loader(bonsai.core.tool.Loader): material_index = len(mesh.materials) mesh.materials.append(tool.Ifc.get_object(style)) - if idx == last_i: + if i == last_i: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace): center = face.calc_center_median() - if (center - co).dot(test_normal) >= 0: + if (center - co).dot(no) >= 0: face.material_index = material_index has_layer_styles = True else: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace): center = face.calc_center_median() - if (center - co).dot(test_normal) < 0 and (center - prev_co).dot(test_normal) >= 0: + if (center - co).dot(no) < 0 and (center - prev_co).dot(no) >= 0: face.material_index = material_index has_layer_styles = True From 34303c2cfe7db3dd7e8c01b57fe611ef4777a0c8 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 28 Jan 2026 23:28:14 +1100 Subject: [PATCH 12/60] Revert "fix to https://github.com/IfcOpenShell/IfcOpenShell/commit/7f87f1fb89fb001320223a4d85e6267f342bf13c: have rotation around the object's origin, not the world origin" This reverts commit 249e68f16395b5b273466930dd21ffefc0ac512a. --- src/bonsai/bonsai/bim/module/model/wall.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 5276e64692..01a2d84b7f 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -578,21 +578,9 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): dot_product = expected_new_world_direction.dot(current_world_direction) angle = acos(min(max(dot_product, -1), 1)) - # Rotate around object's own origin - # Decompose the matrix to get translation, rotation, scale - translation, rotation, scale = obj.matrix_world.decompose() - - # Create rotation matrix and convert to quaternion + # Create and apply rotation matrix rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis) - rotation_quat = rotation_matrix.to_quaternion() - - # Apply rotation to existing rotation (quaternion multiplication) - new_rotation = rotation_quat @ rotation - - # Reconstruct matrix_world with same translation, new rotation, same scale - obj.matrix_world = ( - Matrix.Translation(translation) @ new_rotation.to_matrix().to_4x4() @ Matrix.Scale(1, 4) - ) + obj.matrix_world = rotation_matrix @ obj.matrix_world bpy.context.view_layer.update() bonsai.core.geometry.switch_representation( From 5ce6d927f31c718bb1817e63588a08b3a243f993 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 28 Jan 2026 23:39:45 +1100 Subject: [PATCH 13/60] Revert "fix #7537 - Layer thickness correct when slab is rotated and few other features... " This reverts commit 7f87f1fb89fb001320223a4d85e6267f342bf13c. --- src/bonsai/bonsai/bim/module/model/slab.py | 304 ++++-------------- src/bonsai/bonsai/bim/module/model/wall.py | 155 +++------ src/bonsai/bonsai/tool/collector.py | 2 + src/bonsai/bonsai/tool/loader.py | 70 +--- .../api/geometry/add_slab_representation.py | 35 +- .../api/geometry/add_wall_representation.py | 3 +- 6 files changed, 126 insertions(+), 443 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index caf389165b..7630ef258c 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -40,11 +40,9 @@ import bonsai.core.root import bonsai.core.type import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from bonsai.bim.module.model.decorator import ( - PolylineDecorator, - ProductDecorator, - ProfileDecorator, -) +from math import cos, pi +from mathutils import Vector, Matrix +from bonsai.bim.module.model.decorator import ProfileDecorator, PolylineDecorator, ProductDecorator from bonsai.bim.module.model.polyline import PolylineOperator @@ -270,65 +268,50 @@ class DumbSlabPlaner: if representation: extrusion = tool.Model.get_extrusion(representation) if extrusion: + # TODO Right now we don't have a reliable way to calculate the existing x_angle only based solely on the extrusion direction. + # For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a + # -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation. + # This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach. + existing_x_angle = obj.rotation_euler.x + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) + offset_direction = direction_ratios.copy() + perpendicular_depth = thickness * abs(1 / cos(existing_x_angle)) + perpendicular_offset = layer_offset * abs(1 / cos(existing_x_angle)) / self.unit_scale - # Calculate the actual extrusion angle from vertical - extrusion_angle = 0 - if direction_ratios.length > 0: - cos_angle = direction_ratios.normalized().dot(Vector((0, 0, 1))) - extrusion_angle = acos(min(max(cos_angle, -1), 1)) + # Check angle and z direction to determine whether the extrusion direction is positive or negative + if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or ( + abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0 + ): + # The extrusion direction is positive. If the layer_parameter is set to negative, + # then the we change the extrusion direction. + if layer_params["direction_sense"] == "NEGATIVE": + direction_ratios *= -1 + elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or ( + abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0 + ): + # The extrusion direction is negative. If the layer_parameter is set to positive, + # then the we change the extrusion direction. And the offset direction should remain positive + # for either direction sense, so we change it. + offset_direction *= -1 + if layer_params["direction_sense"] == "POSITIVE": + direction_ratios *= -1 - # FIX: Only apply 1/cos factor when there's actual extrusion slope - if extrusion_angle > 1e-6: - perpendicular_depth = thickness * abs(1 / cos(extrusion_angle)) - perpendicular_offset = layer_offset * abs(1 / cos(extrusion_angle)) / self.unit_scale - else: - perpendicular_depth = thickness - perpendicular_offset = layer_offset / self.unit_scale - - # Check if direction sense needs to be applied - # This should only happen if explicitly requested, not automatically - if layer_params.get("apply_direction_sense", False): - # Store current direction before potential change - old_direction = direction_ratios.copy() - - # Apply direction sense logic - existing_x_angle = extrusion_angle - if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or ( - abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0 - ): - if layer_params["direction_sense"] == "NEGATIVE": - direction_ratios *= -1 - elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or ( - abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0 - ): - offset_direction = direction_ratios.copy() * -1 - if layer_params["direction_sense"] == "POSITIVE": - direction_ratios *= -1 - - # If direction changed, update extrusion with rotation compensation - if (direction_ratios.normalized() - old_direction.normalized()).length > 1e-6: - update_extrusion_direction(element, tuple(direction_ratios), obj) - # After updating direction, get the updated extrusion - extrusion = tool.Model.get_extrusion(representation) - - # Update depth + extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios) extrusion.Depth = perpendicular_depth - # Update position ifc_position = extrusion.Position - if direction_ratios.length > 0: - offset_vector = direction_ratios.normalized() * perpendicular_offset - position = offset_vector - - material = ifcopenshell.util.element.get_material(element) - if material and material.is_a("IfcMaterialLayerSetUsage"): + position = offset_direction * perpendicular_offset + material = ifcopenshell.util.element.get_material(element) + if material: + if material.is_a("IfcMaterialLayerSetUsage"): material.OffsetFromReferenceLine = position.z - - if ifc_position: - ifc_position.Location.Coordinates = position - else: - tool.Model.add_extrusion_position(extrusion, position) + if ifc_position: + ifc_position.Location.Coordinates = position + else: + tool.Model.add_extrusion_position(extrusion, position) else: props = tool.Model.get_model_props() @@ -371,112 +354,6 @@ class DumbSlabPlaner: representation=representation, ) - def update_extrusion_direction( - element: ifcopenshell.entity_instance, new_direction_ratios: tuple, obj: bpy.types.Object = None - ) -> None: - """ - Update extrusion direction while preserving overall object orientation. - - Args: - element: The IFC element - new_direction_ratios: New extrusion direction ratios (x,y,z) - obj: Optional Blender object (will be fetched if not provided) - """ - if not obj: - obj = tool.Ifc.get_object(element) - if not obj: - return - - representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") - if not representation: - return - - extrusion = tool.Model.get_extrusion(representation) - if not extrusion: - return - - # Get current extrusion direction - old_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios) - if old_direction.length == 0: - old_direction = Vector((0, 0, 1)) # Default - - new_direction = Vector(new_direction_ratios) - if new_direction.length == 0: - new_direction = Vector((0, 0, 1)) # Default - - # Normalize both directions - old_direction_normalized = old_direction.normalized() - new_direction_normalized = new_direction.normalized() - - # Store current object matrix - old_matrix = obj.matrix_world.copy() - - # Calculate the rotation needed to keep same orientation - # When extrusion direction changes from A to B relative to local coordinates, - # we need to rotate the object by the inverse of that change - - # Calculate rotation from old to new direction - rotation_axis = old_direction_normalized.cross(new_direction_normalized) - if rotation_axis.length > 1e-6: - rotation_axis.normalized() - dot_product = old_direction_normalized.dot(new_direction_normalized) - angle = acos(min(max(dot_product, -1), 1)) - - # Apply INVERSE rotation to object to compensate - rotation_matrix = Matrix.Rotation(-angle, 4, rotation_axis) - - # Update object rotation - obj.matrix_world = old_matrix @ rotation_matrix - bpy.context.view_layer.update() - - # Update extrusion direction (keeping magnitude) - if old_direction.length > 0: - # Preserve the magnitude of the original direction vector - magnitude = old_direction.length - new_direction = new_direction_normalized * magnitude - - extrusion.ExtrudedDirection.DirectionRatios = tuple(new_direction) - - # Update depth based on new extrusion angle - extrusion_angle = 0 - if new_direction.length > 0: - cos_angle = new_direction_normalized.dot(Vector((0, 0, 1))) - extrusion_angle = acos(min(max(cos_angle, -1), 1)) - - # Get current depth (perpendicular depth) - current_perpendicular_depth = extrusion.Depth - - # If we have material layer info, calculate actual thickness - material = ifcopenshell.util.element.get_material(element) - actual_thickness = current_perpendicular_depth - if material and material.is_a("IfcMaterialLayerSetUsage"): - layer_set = material.ForLayerSet - actual_thickness = sum([l.LayerThickness for l in layer_set.MaterialLayers]) - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - actual_thickness *= unit_scale - - # Convert to perpendicular depth if needed - if extrusion_angle > 1e-6: - new_perpendicular_depth = actual_thickness * abs(1 / cos(extrusion_angle)) - else: - new_perpendicular_depth = actual_thickness - - extrusion.Depth = new_perpendicular_depth - - # Update position offset if needed - if extrusion.Position: - # Recalculate offset based on new direction - material = ifcopenshell.util.element.get_material(element) - if material and material.is_a("IfcMaterialLayerSetUsage"): - offset = material.OffsetFromReferenceLine - if extrusion_angle > 1e-6: - perpendicular_offset = offset * abs(1 / cos(extrusion_angle)) - else: - perpendicular_offset = offset - - offset_vector = new_direction_normalized * perpendicular_offset - extrusion.Position.Location.Coordinates = tuple(offset_vector) - class EnableEditingSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_sketch_extrusion_profile" @@ -752,8 +629,6 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): existing_x_angle = tool.Model.get_existing_x_angle(extrusion) layer_params = tool.Model.get_material_layer_parameters(element) - usage_type = tool.Model.get_usage_type(element) - # TODO: review #7537 properly, this is a quick fix but something doesn't seem right. original_rotation_x = 0 if extrusion.Position: @@ -768,49 +643,22 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): tranlation_matrix = Matrix.Translation(rot_offset) position = position @ tranlation_matrix - # For AXIS3 with dual rotation: Reset rotation to zero so profile is horizontal - if usage_type == "LAYER3": - # Store original rotation for later restoration - original_rotation_x = obj.rotation_euler.x - obj["pre_edit_rotation_x"] = original_rotation_x - - # Reset rotation to zero - profile will be horizontal - current_z_rot = obj.rotation_euler.z - obj.rotation_euler.x = 0.0 - obj.rotation_euler.z = current_z_rot - else: - # Original behavior: Restore Object rotation to zero - local_rot_mat = obj.rotation_euler.to_matrix() - rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X") - new_rot_mat = local_rot_mat.to_4x4() @ rot_mat - new_rot_euler = new_rot_mat.to_euler() - obj.rotation_euler = new_rot_euler + # Restore Object rotation to zero + local_rot_mat = obj.rotation_euler.to_matrix() + rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X") + new_rot_mat = local_rot_mat.to_4x4() @ rot_mat + new_rot_euler = new_rot_mat.to_euler() + obj.rotation_euler = new_rot_euler else: position = Matrix() - # Import profile with correct x_angle - if usage_type == "LAYER3": - # For LAYER3: Use x_angle=0 and scale by cos(rotation) to get horizontal projection - obj_x_rotation = original_rotation_x # Use stored original rotation - scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0 - - # Import with x_angle=0 - tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=0) - - # Scale the Y coordinates by cos(rotation) to get horizontal projection - bpy.ops.object.mode_set(mode="OBJECT") - for vert in obj.data.vertices: - vert.co.y *= scale_factor - else: - # For other types: Use existing_x_angle - tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle) + tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle) bpy.ops.object.mode_set(mode="EDIT") ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_extrusion_profile(context)) if not bpy.app.background: tool.Blender.set_viewport_tool("bim.cad_tool") - return {"FINISHED"} @@ -832,7 +680,6 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): extrusion = tool.Model.get_extrusion(body) existing_x_angle = tool.Model.get_existing_x_angle(extrusion) layer_params = tool.Model.get_material_layer_parameters(element) - usage_type = tool.Model.get_usage_type(element) if extrusion.Position: position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist()) @@ -846,38 +693,17 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): tranlation_matrix = Matrix.Translation(rot_offset) position = position @ tranlation_matrix - # Restore rotation - if usage_type == "LAYER3": - # Restore original rotation from before editing - if "pre_edit_rotation_x" in obj: - current_z_rot = obj.rotation_euler.z - obj.rotation_euler.x = obj["pre_edit_rotation_x"] - obj.rotation_euler.z = current_z_rot - del obj["pre_edit_rotation_x"] - else: - # Original behavior - local_rot_mat = obj.rotation_euler.to_matrix() - rot_mat = Matrix.Rotation(existing_x_angle, 4, "X") - new_rot_mat = local_rot_mat.to_4x4() @ rot_mat - new_rot_euler = new_rot_mat.to_euler() - obj.rotation_euler = new_rot_euler + # Restore Object rotation to x_angle + local_rot_mat = obj.rotation_euler.to_matrix() + rot_mat = Matrix.Rotation(existing_x_angle, 4, "X") + new_rot_mat = local_rot_mat.to_4x4() @ rot_mat + new_rot_euler = new_rot_mat.to_euler() + obj.rotation_euler = new_rot_euler else: position = Matrix() - # Export profile with correct x_angle - if usage_type == "LAYER3": - # Scale Y coordinates back up before exporting - obj_x_rotation = obj.rotation_euler.x - scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0 - - # Un-scale the profile before exporting - for vert in obj.data.vertices: - vert.co.y /= scale_factor # Inverse of import scaling - - profile = tool.Model.export_profile(obj, position=position, x_angle=0) - else: - profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle) + profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle) if not profile: @@ -929,28 +755,6 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): tool.Ifc.get(), product=element, representation=new_footprint ) - footprint_context = ifcopenshell.util.representation.get_context( - tool.Ifc.get(), "Plan", "FootPrint", "SKETCH_VIEW" - ) - if not footprint_context: - return - - curves = [profile.OuterCurve] - if profile.is_a("IfcArbitraryProfileDefWithVoids"): - curves.extend(profile.InnerCurves) - new_footprint = ifcopenshell.api.geometry.add_footprint_representation( - tool.Ifc.get(), context=footprint_context, curves=curves - ) - old_footprint = ifcopenshell.util.representation.get_representation(element, "Plan", "FootPrint", "SKETCH_VIEW") - if old_footprint: - for inverse in tool.Ifc.get().get_inverse(old_footprint): - ifcopenshell.util.element.replace_attribute(inverse, old_footprint, new_footprint) - bonsai.core.geometry.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_footprint) - else: - ifcopenshell.api.geometry.assign_representation( - tool.Ifc.get(), product=element, representation=new_footprint - ) - class ResetVertex(bpy.types.Operator): bl_idname = "bim.reset_vertex" diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 01a2d84b7f..d98a81f62d 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -48,6 +48,8 @@ import bonsai.core.root import bonsai.core.type import bonsai.tool as tool from bonsai.bim.ifc import IfcStore +from math import pi, sin, cos, degrees, atan2 +from mathutils import Vector, Matrix from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator from bonsai.bim.module.model.opening import FilledOpeningGenerator from bonsai.bim.module.model.polyline import PolylineOperator @@ -403,42 +405,24 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: continue - extrusion = tool.Model.get_extrusion(representation) if not extrusion: continue - - # Get extrusion direction x, y, z = extrusion.ExtrudedDirection.DirectionRatios - - # Calculate angle from vertical x_angle = Vector((0, 1)).angle_signed(Vector((y, z))) - - # For sloped walls, compensate so VERTICAL height = target depth - cos_angle = cos(x_angle) - compensation_factor = abs(1 / cos_angle) if abs(cos_angle) > 1e-6 else 1.0 - new_depth_ifc = (self.depth / si_conversion) * compensation_factor - - extrusion.Depth = new_depth_ifc - - # IMPORTANT: Refresh the geometry to reflect the IFC changes - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=representation, - ) - + extrusion.Depth = self.depth / si_conversion * (1 / cos(x_angle)) if tool.Model.get_usage_type(element) == "LAYER2": for rel in element.ConnectedFrom: if rel.is_a() == "IfcRelConnectsElements": - related_element = rel.RelatedElement - if related_element.is_a() == "IfcWall": - layer2_objs.append(tool.Ifc.get_object(related_element)) + ifcopenshell.api.geometry.disconnect_element( + ifc_file, + relating_element=rel.RelatingElement, + related_element=element, + ) + layer2_objs.append(obj) if layer2_objs: tool.Model.recalculate_walls(layer2_objs) - return {"FINISHED"} @@ -458,131 +442,81 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): layer2_objs: list[bpy.types.Object] = [] - builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + x_angle = 0 if tool.Cad.is_x(self.x_angle, 0, tolerance=0.001) else self.x_angle + x_angle = 0 if tool.Cad.is_x(self.x_angle, pi, tolerance=0.001) else self.x_angle unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - x_angle = self.x_angle + selected_objs = tool.Model.get_selected_mesh_ifc_objects() + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) - for obj in context.selected_objects: + for obj in selected_objs: element = tool.Ifc.get_entity(obj) - if not element: - continue - + assert element representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: continue extrusion = tool.Model.get_extrusion(representation) if not extrusion: continue - - # Get current object rotation matrix - obj_rotation = obj.matrix_world.to_3x3() - - # Get current extrusion direction in LOCAL coordinates - current_local_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios) - if current_local_direction.length == 0: - current_local_direction = Vector((0, 0, 1)) - current_local_direction_normalized = current_local_direction.normalized() - - # Calculate what the current extrusion direction is in WORLD coordinates - current_world_direction = obj_rotation @ current_local_direction_normalized - existing_x_angle = tool.Model.get_existing_x_angle(extrusion) existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle - - # Calculate the NEW local extrusion direction based on x_angle - new_local_direction = Vector((0.0, sin(x_angle), cos(x_angle))) - - # Check if extrusion direction is actually changing - current_local_norm = current_local_direction_normalized - new_local_norm = new_local_direction.normalized() - - # Compare the LOCAL directions - local_direction_changed = (new_local_norm - current_local_norm).length > 1e-6 - if tool.Model.get_usage_type(element) == "LAYER2": + x, y, z = extrusion.ExtrudedDirection.DirectionRatios depth = extrusion.Depth / abs(1 / cos(existing_x_angle)) perpendicular_depth = depth * abs(1 / cos(x_angle)) - - # Update extrusion direction - if local_direction_changed: - extrusion.ExtrudedDirection.DirectionRatios = tuple(new_local_direction) - - # Always update depth - extrusion.Depth = perpendicular_depth + extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle)) layer2_objs.append(obj) - + extrusion.Depth = perpendicular_depth else: if tool.Model.get_usage_type(element) == "LAYER3": - # For slabs, handle polyline scaling - existing_obj_x_angle = obj.rotation_euler.x - existing_obj_x_angle = ( - 0 if tool.Cad.is_x(existing_obj_x_angle, 0, tolerance=0.001) else existing_obj_x_angle - ) - existing_obj_x_angle = ( - 0 if tool.Cad.is_x(existing_obj_x_angle, pi, tolerance=0.001) else existing_obj_x_angle - ) + existing_x_angle = obj.rotation_euler.x + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle - # Scale the polyline coordinates coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve) coord_list = [ (p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list - ] # Reset the transformation + ] # Reset the transformation and returns to the original points with 0 degrees coord_list = [ (p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list ] # Apply the transformation for the new x_angle builder.set_polyline_coords(extrusion.SweptArea.OuterCurve, coord_list) - # Calculate new extrusion direction with direction sense - base_local_direction = Vector((0.0, sin(x_angle), cos(x_angle))) + # The extrusion direction calculated previously default to the positive direction + # Here we set the extrusion direction to negative if that's the case + direction_ratios = Vector((0.0, sin(x_angle), cos(x_angle))) + # direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) layer_params = tool.Model.get_material_layer_parameters(element) perpendicular_depth = layer_params["thickness"] * abs(1 / cos(x_angle)) / unit_scale perpendicular_offset = layer_params["offset"] * abs(1 / cos(x_angle)) / unit_scale - offset_direction = base_local_direction.copy() + offset_direction = direction_ratios.copy() - # Apply direction sense - final_local_direction = base_local_direction.copy() - if (abs(x_angle) < (pi / 2) and base_local_direction.z > 0) or ( - abs(x_angle) > (pi / 2) and base_local_direction.z < 0 + # Check angle and z direction to determine whether the extrusion direction is positive or negative + if (abs(x_angle) < (pi / 2) and direction_ratios.z > 0) or ( + abs(x_angle) > (pi / 2) and direction_ratios.z < 0 ): + # The extrusion direction is positive. If the layer_parameter is set to negative, + # then the we change the extrusion direction. if layer_params["direction_sense"] == "NEGATIVE": - final_local_direction *= -1 - elif (x_angle > (pi / 2) and base_local_direction.z > 0) or ( - x_angle < (pi / 2) and base_local_direction.z < 0 + direction_ratios *= -1 + elif ((x_angle) > (pi / 2) and direction_ratios.z > 0) or ( + (x_angle) < (pi / 2) and direction_ratios.z < 0 ): + # The extrusion direction is negative. If the layer_parameter is set to positive, + # then the we change the extrusion direction. + # then the we change the extrusion direction. And the offset direction should remain positive + # for either direction sense, so we change it. offset_direction *= -1 if layer_params["direction_sense"] == "POSITIVE": - final_local_direction *= -1 + direction_ratios *= -1 - # Check if extrusion direction actually changed - final_local_norm = final_local_direction.normalized() - local_direction_changed = (final_local_norm - current_local_norm).length > 1e-6 - - # Update extrusion properties - extrusion.ExtrudedDirection.DirectionRatios = tuple(final_local_direction) + extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios) extrusion.Depth = perpendicular_depth if extrusion.Position or perpendicular_offset != 0: position = offset_direction * perpendicular_offset tool.Model.add_extrusion_position(extrusion, position) - # Adjust object rotation if extrusion direction changed - if local_direction_changed: - # Calculate what the NEW world direction would be with current object rotation - expected_new_world_direction = obj_rotation @ final_local_norm - - # The rotation needed is from expected_new_world_direction to current_world_direction - rotation_axis = expected_new_world_direction.cross(current_world_direction) - if rotation_axis.length > 1e-6: - rotation_axis.normalize() - dot_product = expected_new_world_direction.dot(current_world_direction) - angle = acos(min(max(dot_product, -1), 1)) - - # Create and apply rotation matrix - rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis) - obj.matrix_world = rotation_matrix @ obj.matrix_world - bpy.context.view_layer.update() - bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, @@ -590,6 +524,12 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): representation=representation, ) + # Object rotation + current_z_rot = obj.rotation_euler.z + rot_mat = mathutils.Matrix.Rotation(x_angle, 4, "X") + obj.rotation_euler = rot_mat.to_euler() + obj.rotation_euler.z = current_z_rot + if layer2_objs: tool.Model.recalculate_walls(layer2_objs) return {"FINISHED"} @@ -1084,7 +1024,6 @@ class DumbWallGenerator: obj=obj, representation=representation, ) - pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="EPset_Parametric") ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Engine": "Bonsai.DumbLayer2"}) material = ifcopenshell.util.element.get_material(element) diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index 01c13d6cec..74a55d7dc1 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -46,6 +46,8 @@ class Collector(bonsai.core.tool.Collector): # Note that tool.Geometry.is_locked is only checked within the if # statements for efficiency as it is a slow check. tool.Geometry.lock_scale(obj) + if element.is_a("IfcSlab"): + tool.Geometry.lock_rotation(obj, x=True) if element.is_a("IfcGridAxis"): if tool.Geometry.is_locked(element): diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index ebc6722bee..8b14de9362 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -1033,57 +1033,28 @@ class Loader(bonsai.core.tool.Loader): sense_factor = 1 else: return mesh - if len(layer_set.MaterialLayers) == 1: return mesh - bm = bmesh.new() bm.from_mesh(mesh) - prev_co = None - advance_direction = None # Will store direction to advance planes - if not usage: - sense_factor = 1 + sense_factor = 1 # Assume the extrusion vector points in the direction sense no = cls.get_extrusion_vector(element).normalized() co = Vector((0.0, 0.0, offset)) - advance_direction = no elif usage.LayerSetDirection == "AXIS2": co = Vector((0.0, offset, 0.0)) - - # Get LOCAL extrusion direction - local_extrusion = Vector([0.0, 0.0, 1.0]) - if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): - for item in ifcopenshell.util.representation.resolve_representation(body).Items: - while item.is_a("IfcBooleanResult"): - item = item.FirstOperand - if item.is_a("IfcExtrudedAreaSolid"): - local_extrusion = Vector(item.ExtrudedDirection.DirectionRatios).normalized() - break - - # Thickness direction: perpendicular to extrusion and length - thickness_dir = local_extrusion.cross(Vector([1.0, 0.0, 0.0])).normalized() - - # Ensure it points in POSITIVE Y (through wall thickness, not backwards) - if thickness_dir.y < 0: - thickness_dir = -thickness_dir - - no = thickness_dir - advance_direction = thickness_dir + no = cls.get_extrusion_vector(element).normalized() + no = no.cross(Vector([1.0, 0.0, 0.0])) elif usage.LayerSetDirection == "AXIS3": co = Vector((0.0, 0.0, offset)) no = cls.get_extrusion_vector(element).normalized() no = Vector([0.0, 0.0, 1.0]) - advance_direction = no elif usage.LayerSetDirection == "AXIS1": co = Vector((0.0, 0.0, offset)) no = cls.get_extrusion_vector(element).normalized() no = Vector([1.0, 0.0, 0.0]) - advance_direction = no - no *= sense_factor - advance_direction *= sense_factor - # Cache this body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") styles = {} @@ -1091,25 +1062,20 @@ class Loader(bonsai.core.tool.Loader): for i, material in enumerate(mesh.materials): if style := tool.Ifc.get_entity(material): styles[style] = i - last_i = len(layer_set.MaterialLayers) - 1 for i, layer in enumerate(layer_set.MaterialLayers): if i != last_i: prev_co = co.copy() - # Use advance_direction (not no) to move planes! - co += advance_direction * layer.LayerThickness * cls.unit_scale - + co += no * layer.LayerThickness * cls.unit_scale bisect_geom = bmesh.ops.bisect_plane( bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no ) bmesh.ops.duplicate(bm, geom=bisect_geom["geom_cut"]) - if not (style := ifcopenshell.util.representation.get_material_style(layer.Material, body)): continue if (material_index := styles.get(style, None)) is None: material_index = len(mesh.materials) mesh.materials.append(tool.Ifc.get_object(style)) - if i == last_i: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace): @@ -1134,35 +1100,13 @@ class Loader(bonsai.core.tool.Loader): return mesh @classmethod - def get_extrusion_vector(cls, element): - """Get the extrusion direction in WORLD coordinates (accounting for object rotation)""" - if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): + def get_extrusion_vector(cls, wall): + if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"): for item in ifcopenshell.util.representation.resolve_representation(body).Items: while item.is_a("IfcBooleanResult"): item = item.FirstOperand if item.is_a("IfcExtrudedAreaSolid"): - local_direction = Vector(item.ExtrudedDirection.DirectionRatios) - - # Transform to world coordinates using object rotation - obj = tool.Ifc.get_object(element) - if obj: - # Apply object rotation to get actual world direction - world_direction = obj.matrix_world.to_3x3() @ local_direction - return world_direction - - return local_direction - return Vector([0.0, 0.0, 1.0]) - - @classmethod - def get_local_extrusion_vector(cls, element): - """Get the extrusion direction in LOCAL coordinates (from IFC, no object rotation)""" - if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): - for item in ifcopenshell.util.representation.resolve_representation(body).Items: - while item.is_a("IfcBooleanResult"): - item = item.FirstOperand - if item.is_a("IfcExtrudedAreaSolid"): - local_direction = Vector(item.ExtrudedDirection.DirectionRatios) - return local_direction + return Vector(item.ExtrudedDirection.DirectionRatios) return Vector([0.0, 0.0, 1.0]) @classmethod diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py index b5029dc934..6e600eca9a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py @@ -100,15 +100,10 @@ class Usecase: size = self.convert_si_to_unit(1) points = ((0.0, 0.0), (size, 0.0), (size, size), (0.0, size), (0.0, 0.0)) if self.polyline: - # Only scale polyline if we have actual slope - if self.x_angle and abs(self.x_angle) > 1e-6: - points = [ - (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle)))) - for p in self.polyline - ] - else: - points = [(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1])) for p in self.polyline] - + points = [ + (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle)))) + for p in self.polyline + ] if self.file.schema == "IFC2X3": curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points]) else: @@ -119,23 +114,21 @@ class Usecase: else: direction_ratios = (0.0, 0.0, 1.0) + offset_direction = direction_ratios # offset direction doesn't change if direction_sense is negative extrusion_direction = self.file.createIfcDirection(direction_ratios) + if self.direction_sense == "NEGATIVE": + direction_ratios = tuple(-n for n in direction_ratios) + extrusion_direction = self.file.createIfcDirection(direction_ratios) - # Calculate depth based on extrusion angle - extrusion_angle = abs(self.x_angle) if self.x_angle else 0 - if extrusion_angle > 1e-6: - perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(extrusion_angle)) - perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(extrusion_angle)) - else: - perpendicular_depth = self.convert_si_to_unit(self.depth) - perpendicular_offset = self.convert_si_to_unit(self.offset) - + perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(self.x_angle)) + perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(self.x_angle)) position = None + # default position for IFC2X3 where .Position is not optional if self.file.schema == "IFC2X3" or self.offset != 0: position_vector = ( - direction_ratios[0] * perpendicular_offset, - direction_ratios[1] * perpendicular_offset, - direction_ratios[2] * perpendicular_offset, + offset_direction[0] * perpendicular_offset, + offset_direction[1] * perpendicular_offset, + offset_direction[2] * perpendicular_offset, ) position = self.file.createIfcAxis2Placement3D( self.file.createIfcCartesianPoint(position_vector), diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py index 69f9a82fa7..ff62bae474 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py @@ -85,6 +85,7 @@ class Usecase: def create_item(self) -> ifcopenshell.entity_instance: length = self.convert_si_to_unit(self.settings["length"]) thickness = self.convert_si_to_unit(self.settings["thickness"]) + thickness *= 1 / cos(self.settings["x_angle"]) if self.settings["direction_sense"] == "NEGATIVE": thickness *= -1 points = ( @@ -112,7 +113,7 @@ class Usecase: self.file.createIfcDirection((1.0, 0.0, 0.0)), ), extrusion_direction, - self.convert_si_to_unit(self.settings["height"]), + self.convert_si_to_unit(self.settings["height"]) * abs(1 / cos(self.settings["x_angle"])), ) if self.settings["booleans"]: extrusion = self.apply_booleans(extrusion) From 0e8c98a7bbea6d5680b33a75dfbba36d38b47ca5 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 28 Jan 2026 18:34:14 -0600 Subject: [PATCH 14/60] Fix: Handle emoji encoding error in dev_environment.py Prevents UnicodeEncodeError on Windows when displaying success message. --- src/bonsai/scripts/dev_environment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/scripts/dev_environment.py b/src/bonsai/scripts/dev_environment.py index 4598d0bf49..d16edad96d 100644 --- a/src/bonsai/scripts/dev_environment.py +++ b/src/bonsai/scripts/dev_environment.py @@ -193,7 +193,7 @@ def main() -> None: print(f"Downloading {url} -> {filepath}") urllib.request.urlretrieve(url, filepath) - input("Dev environment is all set. 🎉🎉\nPress Enter to continue..." "") + input("Dev environment is all set!! \nPress Enter to continue...") if __name__ == "__main__": From cc995db98ba7b852312478f25811b32c083f5ebe Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 29 Jan 2026 19:12:02 +1100 Subject: [PATCH 15/60] Minor fix to 46a6356 to use the collector tool This has a few advantages: - The collection logic is centralised - The collection logic is configurable based on the collection mode - The name is not hardcoded --- src/bonsai/bonsai/bim/module/spatial/prop.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/spatial/prop.py b/src/bonsai/bonsai/bim/module/spatial/prop.py index 5c48566d08..3d3d2e74f4 100644 --- a/src/bonsai/bonsai/bim/module/spatial/prop.py +++ b/src/bonsai/bonsai/bim/module/spatial/prop.py @@ -99,8 +99,7 @@ def update_name(self: "BIMContainer", context: bpy.types.Context) -> None: tool.Spatial.edit_container_name(element, self.name) if obj := tool.Ifc.get_object(element): tool.Root.set_object_name(obj, element) - if collection := tool.Blender.get_object_bim_props(obj).collection: - collection.name = f"{element.is_a()}/{element.Name or 'Unnamed'}" + tool.Collector.assign(obj) bonsai.bim.handler.refresh_ui_data() From c1aa1dedba96d11497f4f77958ea7d50d0f2e034 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 29 Jan 2026 19:33:44 +1100 Subject: [PATCH 16/60] Fix #7615. Forgot a line when refactoring type geometry regeneration. Added test. --- src/bonsai/bonsai/bim/module/model/slab.py | 1 + src/bonsai/test/bim/feature/type.feature | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 7630ef258c..3dcbf2aef0 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -249,6 +249,7 @@ class DumbSlabPlaner: self.change_thickness(element, total_thickness) def change_thickness(self, element: ifcopenshell.entity_instance, thickness: float) -> None: + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) if tool.Model.get_usage_type(element) != "LAYER3": return layer_params = tool.Model.get_material_layer_parameters(element) diff --git a/src/bonsai/test/bim/feature/type.feature b/src/bonsai/test/bim/feature/type.feature index 1efefef446..9bae601b30 100644 --- a/src/bonsai/test/bim/feature/type.feature +++ b/src/bonsai/test/bim/feature/type.feature @@ -110,7 +110,7 @@ Scenario: Assign type - assign to a type with a material layer set, which automa And the object "IfcWall/Unnamed" has a "100" thick layered material containing the material "Default" And the object "IfcWall/Unnamed" dimensions are ".5,.1,.5" -Scenario: Assign type - assign to a different type with a material layer set +Scenario: Assign type - assign to a different type with a LAYER2 material layer set Given an empty IFC project And I add a cube And the object "Cube" is selected @@ -149,6 +149,23 @@ Scenario: Assign type - assign to a different type with a material layer set Then the object "IfcWall/Cube" has a "200" thick layered material containing the material "Default" And the object "IfcWall/Cube" dimensions are "1,.2,1" +Scenario: Assign type - assign to a different type with a LAYER3 material layer set + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + When I press "bim.add_occurrence" + Then the object "IfcSlab/Slab" is an "IfcSlab" + And the object "IfcSlab/Slab" dimensions are "1,1,0.2" + And the object "IfcSlab/Slab" bottom left corner is at "0,0,0" + And the object "IfcSlab/Slab" top right corner is at "1,1,0.2" + When I look at the "Type" panel + And I click "GREASEPENCIL" + And I set the "relating_type" property to "FLR300" + And I click "CHECKMARK" + Then the object "IfcSlab/Slab" dimensions are "1,1,0.3" + Scenario: Assign type - assign to a type with a material profile set Given an empty IFC project And I add a cube From 966751a928aa77163b8829029463f98774d33754 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Thu, 29 Jan 2026 13:10:54 +0100 Subject: [PATCH 17/60] Add support for FLOW_SEGMENT_RECTANGULAR_HOLLOW in profile creation in the MEP Segment Tool --- src/bonsai/bonsai/bim/module/root/data.py | 5 +++++ src/bonsai/bonsai/bim/module/root/operator.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/root/data.py b/src/bonsai/bonsai/bim/module/root/data.py index 66412e7d48..d2516559b4 100644 --- a/src/bonsai/bonsai/bim/module/root/data.py +++ b/src/bonsai/bonsai/bim/module/root/data.py @@ -191,6 +191,11 @@ class IfcClassData: "Rectangular Distribution Segment", "Works similarly to Profile, has distribution ports", ), + ( + "FLOW_SEGMENT_RECTANGULAR_HOLLOW", + "Rectangular Hollow Distribution Segment", + "Works similarly to Profile, has distribution ports", + ), ( "FLOW_SEGMENT_CIRCULAR", "Circular Distribution Segment", diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index a2ac73f200..dc1d74f027 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -697,6 +697,24 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): XDim=default_x_dim / unit_scale, YDim=default_y_dim / unit_scale, ) + elif representation_template == "FLOW_SEGMENT_RECTANGULAR_HOLLOW": + default_x_dim = 0.4 + default_y_dim = 0.2 + default_thickness = 0.005 + default_inner_fillet_radius = 0.005 + default_outer_fillet_radius = 0.005 + profile_name = f"{props.ifc_class}-{default_x_dim*1000}x{default_y_dim*1000}" + profile = tool.Ifc.get().create_entity( + "IfcRectangleHollowProfileDef", + ProfileName=profile_name, + ProfileType="AREA", + XDim=default_x_dim / unit_scale, + YDim=default_y_dim / unit_scale, + WallThickness=default_thickness / unit_scale, + InnerFilletRadius=default_inner_fillet_radius / unit_scale, + OuterFilletRadius=default_outer_fillet_radius / unit_scale, + ) + elif representation_template == "FLOW_SEGMENT_CIRCULAR": default_diameter = 0.1 profile_name = f"{props.ifc_class}-{default_diameter*1000}" From d06f5a6cb9fb279d9ec758a1b99e82dcc3ee6ea6 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Thu, 29 Jan 2026 13:12:47 +0100 Subject: [PATCH 18/60] Add thickness dimension to profile name --- src/bonsai/bonsai/bim/module/root/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index dc1d74f027..73f23deb9e 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -703,7 +703,7 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): default_thickness = 0.005 default_inner_fillet_radius = 0.005 default_outer_fillet_radius = 0.005 - profile_name = f"{props.ifc_class}-{default_x_dim*1000}x{default_y_dim*1000}" + profile_name = f"{props.ifc_class}-{default_x_dim*1000}x{default_y_dim*1000}x{default_thickness*1000}" profile = tool.Ifc.get().create_entity( "IfcRectangleHollowProfileDef", ProfileName=profile_name, From e47694458f210b092126b748830cbef0cecc7899 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Thu, 29 Jan 2026 23:21:14 +0100 Subject: [PATCH 19/60] Add support for FLOW_SEGMENT_U_SHAPE in the AddElement operator --- src/bonsai/bonsai/bim/module/root/data.py | 5 +++++ src/bonsai/bonsai/bim/module/root/operator.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/root/data.py b/src/bonsai/bonsai/bim/module/root/data.py index d2516559b4..c23ea49e33 100644 --- a/src/bonsai/bonsai/bim/module/root/data.py +++ b/src/bonsai/bonsai/bim/module/root/data.py @@ -206,6 +206,11 @@ class IfcClassData: "Circular Hollow Distribution Segment", "Works similarly to Profile, has distribution ports", ), + ( + "FLOW_SEGMENT_U_SHAPE", + "U-Shape Distribution Segment", + "Uses IfcUShapeProfileDef, has distribution ports", + ), ) ) diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 73f23deb9e..983429d311 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -735,6 +735,21 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): Radius=(default_diameter / 2) / unit_scale, WallThickness=default_thickness, ) + elif representation_template == "FLOW_SEGMENT_U_SHAPE": + default_depth = 0.4 + default_flange_width = 0.2 + default_web_thickness = 0.005 + default_flange_thickness = 0.005 + profile_name = f"{props.ifc_class}-{default_depth*1000}x{default_flange_width*1000}x{default_web_thickness*1000}x{default_flange_thickness*1000}" + profile = tool.Ifc.get().create_entity( + "IfcUShapeProfileDef", + ProfileName=profile_name, + ProfileType="AREA", + Depth=default_depth / unit_scale, + FlangeWidth=default_flange_width / unit_scale, + WebThickness=default_web_thickness / unit_scale, + FlangeThickness=default_flange_thickness / unit_scale, + ) rel = ifcopenshell.api.material.assign_material( tool.Ifc.get(), products=[element], type="IfcMaterialProfileSet" From 83d97d7e9588a6c8a930c5262fa93967f5ee7515 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 30 Jan 2026 17:18:02 +1100 Subject: [PATCH 20/60] Fix #7616. Make regenerate array an operator instead of an array preference Sync children was a bit odd because it's not actually an "array parameter" per se, just a way to regenerate. It's now an operator. There was a deeper issue I encountered where the way arrays work is that they duplicate the parent element. (first encountered in e51d2d ) However, the duplication code has special array handling too. To avoid issues with this cyclical coupling the previous solution was to reimplement object duplication (with all sorts of pitfalls that has). Now, I've tried to decouple it further by clearing all array psets prior to any change, and readding the pset after everything has been regenerated. This can be improved upon but I don't feel confident until there is more comprehensive test coverage for the duplicate operator. --- .../bonsai/bim/module/model/__init__.py | 3 +- src/bonsai/bonsai/bim/module/model/array.py | 59 ++++++++++++------- src/bonsai/bonsai/bim/module/model/prop.py | 5 -- src/bonsai/bonsai/bim/module/model/ui.py | 2 +- src/bonsai/bonsai/tool/model.py | 41 ++++++------- 5 files changed, 59 insertions(+), 51 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 57b4b561d0..9fbd631003 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -50,9 +50,10 @@ classes = ( array.EditArray, array.EnableEditingArray, array.ApplyArray, + array.RegenerateArray, array.RemoveArray, - array.SelectArrayParent, array.SelectAllArrayObjects, + array.SelectArrayParent, array.Input3DCursorXArray, array.Input3DCursorYArray, array.Input3DCursorZArray, diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index 3fb9360256..b1c13dbe75 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -61,7 +61,6 @@ class AddArray(bpy.types.Operator, tool.Ifc.Operator): "y": 0.0, "z": 0.0, "use_local_space": True, - "sync_children": False, "method": "OFFSET", } @@ -80,28 +79,27 @@ class AddArray(bpy.types.Operator, tool.Ifc.Operator): pset=pset, properties={"Parent": element.GlobalId, "Data": ifc_file.create_entity("IfcText", json.dumps(data))}, ) - return {"FINISHED"} -class DisableEditingArray(bpy.types.Operator, tool.Ifc.Operator): +class DisableEditingArray(bpy.types.Operator): bl_idname = "bim.disable_editing_array" bl_label = "Disable Editing Array" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def execute(self, context): obj = context.active_object assert obj tool.Model.get_array_props(obj).is_editing = -1 return {"FINISHED"} -class EnableEditingArray(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingArray(bpy.types.Operator): bl_idname = "bim.enable_editing_array" bl_label = "Enable Editing Array" bl_options = {"REGISTER", "UNDO"} item: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): obj = context.active_object assert obj element = tool.Ifc.get_entity(obj) @@ -122,11 +120,9 @@ class EnableEditingArray(bpy.types.Operator, tool.Ifc.Operator): props.y = data["y"] * si_conversion props.z = data["z"] * si_conversion props.use_local_space = data.get("use_local_space", False) - props.sync_children = data.get("sync_children", False) props.method = data.get("method", "OFFSET") props.is_editing = self.item - return {"FINISHED"} @@ -151,31 +147,25 @@ class EditArray(bpy.types.Operator, tool.Ifc.Operator): "y": props.y / si_conversion, "z": props.z / si_conversion, "use_local_space": props.use_local_space, - "sync_children": props.sync_children, "method": props.method, } props.is_editing = -1 try: - parent = tool.Ifc.get_object(tool.Ifc.get().by_guid(pset["Parent"])) + parent_element = tool.Ifc.get().by_guid(pset["Parent"]) + parent = tool.Ifc.get_object(parent_element) except: return {"FINISHED"} + tool.Blender.Modifier.Array.remove_constraints(parent_element) tool.Model.regenerate_array(parent, data) - - pset = tool.Ifc.get().by_id(pset["id"]) - data = tool.Ifc.get().createIfcText(json.dumps(data)) - ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data}) - tool.Blender.Modifier.Array.set_children_lock_state(element, self.item, True) tool.Blender.Modifier.Array.constrain_children_to_parent(element) # clears the relating_array_object so it doesn't show again next time props.relating_array_object = None - return {"FINISHED"} - class ApplyArray(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.apply_array" @@ -192,6 +182,33 @@ class ApplyArray(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} +class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.regenerate_array" + bl_label = "Regenerate Array" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + obj = context.active_object + element = tool.Ifc.get_entity(obj) + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + try: + parent_element = tool.Ifc.get().by_guid(pset["Parent"]) + parent = tool.Ifc.get_object(parent_element) + except: + return {"FINISHED"} + pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") + arrays = json.loads(pset["Data"]) + pset = tool.Ifc.get().by_id(pset["id"]) + for array in arrays: + for child in set(array["children"]): + if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)): + tool.Geometry.delete_ifc_object(child_obj) + array["children"].clear() + print('cleared array', arrays) + tool.Model.regenerate_array(obj, arrays) + tool.Blender.Modifier.Array.constrain_children_to_parent(element) + + class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_array" bl_label = "Remove Array" @@ -216,7 +233,8 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): props.is_editing = -1 try: - parent = tool.Ifc.get_object(tool.Ifc.get().by_guid(pset["Parent"])) + parent_element = tool.Ifc.get().by_guid(pset["Parent"]) + parent = tool.Ifc.get_object(parent_element) except: return {"FINISHED"} @@ -226,9 +244,10 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): if not self.keep_objs: data[self.item]["count"] = 1 + tool.Blender.Modifier.Array.remove_constraints(parent_element) tool.Model.regenerate_array(parent, data, array_layers_to_apply=[self.item] if self.keep_objs else []) - pset = tool.Ifc.get().by_id(pset["id"]) + pset = tool.Pset.get_element_pset(element, "BBIM_Array") if len(data) == 1: ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset) else: @@ -237,8 +256,6 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data}) tool.Blender.Modifier.Array.constrain_children_to_parent(element) - return {"FINISHED"} - class SelectArrayParent(bpy.types.Operator): bl_idname = "bim.select_array_parent" diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index 9f2664190d..ec79a49196 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -387,11 +387,6 @@ class BIMArrayProperties(PropertyGroup): name="Method", default="OFFSET", ) - sync_children: bpy.props.BoolProperty( - name="Sync Children", - description="Regenerate all children based on the parent object", - default=False, - ) relating_array_object: bpy.props.PointerProperty( type=bpy.types.Object, name="Copy Array Properties", diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index 2c931f4f45..d8a297433b 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -229,6 +229,7 @@ class BIM_PT_array(bpy.types.Panel): if ArrayData.data["parameters"]: row = self.layout.row(align=True) row.label(text=ArrayData.data["parameters"]["parent_name"], icon="CON_CHILDOF") + row.operator("bim.regenerate_array", icon="FILE_REFRESH", text="") row.operator("bim.select_array_parent", icon="OBJECT_DATA", text="") row.operator("bim.select_all_array_objects", icon="RESTRICT_SELECT_OFF", text="") @@ -246,7 +247,6 @@ class BIM_PT_array(bpy.types.Panel): row.prop(props, "method") row = box.row(align=True) row.prop(props, "use_local_space") - row.prop(props, "sync_children") col = box.column() row = col.row(align=True) row.prop(props, "x") diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 08a0a0fd85..636ec9bc73 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -1051,6 +1051,7 @@ class Model(bonsai.core.tool.Model): tool.Model.regenerate_array(obj, array_data) + array_pset = tool.Pset.get_element_pset(element, "BBIM_Array") json_data = tool.Ifc.get().createIfcText(json.dumps(array_data)) ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data}) @@ -1063,22 +1064,15 @@ class Model(bonsai.core.tool.Model): cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int] = tuple() ) -> None: """`array_layers_to_apply` - list of array layer indices to apply""" - tool.Blender.Modifier.Array.remove_constraints(tool.Ifc.get_entity(parent_obj)) + parent_element = tool.Ifc.get_entity(parent_obj) + + if pset := ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array"): + ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=parent_element, pset=tool.Ifc.get().by_id(pset["id"])) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) obj_stack = [parent_obj] for array_i, array in enumerate(data): - # for `sync_children` we remove all previously generated children to regenerate them again - # to assure they are in complete sync (psets, etc) with the array parent - if array["sync_children"]: - removed_children = set(array["children"]) - for removed_child in removed_children: - element = tool.Ifc.get().by_guid(removed_child) - if obj := tool.Ifc.get_object(element): - tool.Geometry.delete_ifc_object(obj) - array["children"].clear() - child_i = 0 existing_children = set(array["children"]) total_existing_children = len(array["children"]) @@ -1104,22 +1098,19 @@ class Model(bonsai.core.tool.Model): child_obj = tool.Ifc.get_object(child_element) assert child_obj except: - old_to_new, _ = tool.Geometry.duplicate_ifc_objects([obj]) - # TODO Is this correct to assume one child? I really - # don't understand the linked aggregates and array - # behaviour. + old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj]) child_element = next(iter(old_to_new.values()))[0] child_obj = tool.Ifc.get_object(child_element) # add child pset - child_pset = tool.Pset.get_element_pset(child_element, "BBIM_Array") - if child_pset: - ifcopenshell.api.pset.edit_pset( - tool.Ifc.get(), - pset=child_pset, - properties={"Data": None}, - should_purge=False, - ) + if not (child_pset := tool.Pset.get_element_pset(child_element, "BBIM_Array")): + child_pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=child_element, name="BBIM_Array") + ifcopenshell.api.pset.edit_pset( + tool.Ifc.get(), + pset=child_pset, + properties={"Data": None, "Parent": parent_element.GlobalId}, + should_purge=False, + ) # set child object position new_matrix = obj.matrix_world.copy() @@ -1155,6 +1146,10 @@ class Model(bonsai.core.tool.Model): bpy.context.view_layer.update() + pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=parent_element, name="BBIM_Array") + json_data = tool.Ifc.get().createIfcText(json.dumps(data)) + ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}) + @classmethod def replace_object_ifc_representation( cls, From 398cd7571e282a5e25d144ef76008893311dba58 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 30 Jan 2026 17:32:30 +1100 Subject: [PATCH 21/60] Add array feature tests I'm thinking of moving array into its own module. The "model" module is getting a bit full and it'll only grow bigger in the future. --- src/bonsai/pytest.ini | 1 + src/bonsai/test/bim/feature/array.feature | 276 ++++++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 src/bonsai/test/bim/feature/array.feature diff --git a/src/bonsai/pytest.ini b/src/bonsai/pytest.ini index 001313dd30..e628606201 100644 --- a/src/bonsai/pytest.ini +++ b/src/bonsai/pytest.ini @@ -1,6 +1,7 @@ [pytest] markers = aggregate + array attribute boolean boundary diff --git a/src/bonsai/test/bim/feature/array.feature b/src/bonsai/test/bim/feature/array.feature new file mode 100644 index 0000000000..23fa4c0080 --- /dev/null +++ b/src/bonsai/test/bim/feature/array.feature @@ -0,0 +1,276 @@ +@array +Feature: Array + +Scenario: Add array + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + When the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I see "No Array Found" + And I click "ADD" + Then the object "IfcColumn/Column" exists + And I see "Column" + And I see "1 Items" + And I don't see "No Array Found" + And the object "IfcColumn/Column" is at "0,0,0" + +Scenario: Enable editing array + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + When I click "GREASEPENCIL" + Then I see "Count" + And I see "Method" + And I don't see "1 Items" + +Scenario: Disable editing array + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + When I click "CANCEL" + Then I see "1 Items" + And I don't see "Count" + +Scenario: Edit array + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + When I set the "Count" property to "2" + And I click "CHECKMARK" + Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey" + And the object "IfcColumn/Column" is at "0,0,0" + And the object "IfcColumn/Column.001" is at "0,0,0" + And the object "IfcColumn/Column" dimensions are "0.5,0.6,3" + +Scenario: Edit array - offset method + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + When I set the "Count" property to "3" + And I set the "Method" property to "Offset" + And I set the "X" property to "1" + And I click "CHECKMARK" + Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey" + And the object "IfcColumn/Column.002" is in the collection "IfcBuildingStorey/My Storey" + And the object "IfcColumn/Column" is at "0,0,0" + And the object "IfcColumn/Column.001" is at "1,0,0" + And the object "IfcColumn/Column.002" is at "2,0,0" + And the object "IfcColumn/Column" dimensions are "0.5,0.6,3" + And the object "IfcColumn/Column.001" dimensions are "0.5,0.6,3" + And the object "IfcColumn/Column.002" dimensions are "0.5,0.6,3" + +Scenario: Edit array - distribute method + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + When I set the "Count" property to "3" + And I set the "Method" property to "Distribute" + And I set the "X" property to "2" + And I click "CHECKMARK" + Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey" + And the object "IfcColumn/Column.002" is in the collection "IfcBuildingStorey/My Storey" + And the object "IfcColumn/Column" is at "0,0,0" + And the object "IfcColumn/Column.001" is at "1,0,0" + And the object "IfcColumn/Column.002" is at "2,0,0" + And the object "IfcColumn/Column" dimensions are "0.5,0.6,3" + And the object "IfcColumn/Column.001" dimensions are "0.5,0.6,3" + And the object "IfcColumn/Column.002" dimensions are "0.5,0.6,3" + +Scenario: Edit array - local space + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is rotated by "0,0,90" deg + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + When I set the "Count" property to "2" + And I set the "Method" property to "Offset" + And I set the "Use Local Space" property to "TRUE" + And I set the "X" property to "1" + And I click "CHECKMARK" + Then the object "IfcColumn/Column" is at "0,0,0" + And the object "IfcColumn/Column.001" is at "0,1,0" + +Scenario: Edit array - world space + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is rotated by "0,0,90" deg + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + When I set the "Count" property to "2" + And I set the "Method" property to "Offset" + And I set the "Use Local Space" property to "FALSE" + And I set the "X" property to "1" + And I click "CHECKMARK" + Then the object "IfcColumn/Column" is at "0,0,0" + And the object "IfcColumn/Column.001" is at "1,0,0" + +Scenario: Edit array - decrease count + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + When I set the "Count" property to "3" + And I click "CHECKMARK" + And I click "GREASEPENCIL" + When I set the "Count" property to "2" + And I click "CHECKMARK" + Then the object "IfcColumn/Column.001" exists + And the object "IfcColumn/Column.002" does not exist + +Scenario: Edit array - multiple arrays + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + When I set the "Count" property to "3" + And I set the "Method" property to "Offset" + And I set the "X" property to "1" + And I click "CHECKMARK" + And I see "3 Items" + And I click "ADD" + And I click the "GREASEPENCIL" after the text "1 Items (Offset)" + And I set the "Count" property to "2" + And I set the "Method" property to "Offset" + And I set the "Y" property to "1" + And I click the "CHECKMARK" after the text "Count" + Then I see "3 Items" + And I see "2 Items" + Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey" + And the object "IfcColumn/Column.002" is in the collection "IfcBuildingStorey/My Storey" + And the object "IfcColumn/Column.003" is in the collection "IfcBuildingStorey/My Storey" + And the object "IfcColumn/Column.004" is in the collection "IfcBuildingStorey/My Storey" + And the object "IfcColumn/Column.005" is in the collection "IfcBuildingStorey/My Storey" + And the object "IfcColumn/Column" is at "0,0,0" + And the object "IfcColumn/Column.001" is at "1,0,0" + And the object "IfcColumn/Column.002" is at "2,0,0" + And the object "IfcColumn/Column.003" is at "0,1,0" + And the object "IfcColumn/Column.004" is at "1,1,0" + And the object "IfcColumn/Column.005" is at "2,1,0" + And the object "IfcColumn/Column" dimensions are "0.5,0.6,3" + And the object "IfcColumn/Column.001" dimensions are "0.5,0.6,3" + And the object "IfcColumn/Column.002" dimensions are "0.5,0.6,3" + And the object "IfcColumn/Column.003" dimensions are "0.5,0.6,3" + And the object "IfcColumn/Column.004" dimensions are "0.5,0.6,3" + And the object "IfcColumn/Column.005" dimensions are "0.5,0.6,3" + +Scenario: Remove array + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + And I set the "Count" property to "2" + And I set the "Method" property to "Offset" + And I set the "X" property to "1" + And I click "CHECKMARK" + When I click "X" + Then the object "IfcColumn/Column" exists + And the object "IfcColumn/Column.001" does not exist + +Scenario: Regenerate array + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + And I set the "Count" property to "2" + And I click "CHECKMARK" + When I click "FILE_REFRESH" + Then the object "IfcColumn/Column" exists + And the object "IfcColumn/Column.001" exists + +Scenario: Apply array + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + And I set the "Count" property to "2" + And I click "CHECKMARK" + When I click "CHECKMARK" + Then the object "IfcColumn/Column" exists + And the object "IfcColumn/Column.001" exists + And I see "No Array Found" + +Scenario: Select array parent + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + And I set the "Count" property to "2" + And I click "CHECKMARK" + When the object "IfcColumn/Column.001" is selected + And I click "OBJECT_DATA" + Then the object "IfcColumn/Column" is selected + And the object "IfcColumn/Column.001" is not selected + +Scenario: Select all array objects + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType" + And I add the construction type + And the object "IfcColumn/Column" is selected + And I look at the "Array" panel + And I click "ADD" + And I click "GREASEPENCIL" + And I set the "Count" property to "2" + And I click "CHECKMARK" + When the object "IfcColumn/Column.001" is selected + And I click "RESTRICT_SELECT_OFF" + Then the object "IfcColumn/Column" is selected + And the object "IfcColumn/Column.001" is selected From 22f91e11b339c4649ee723db055b7e82deac04a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:03:44 +0000 Subject: [PATCH 22/60] Bump tar from 7.5.6 to 7.5.7 in /src/ifctester/webapp Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.6 to 7.5.7. - [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.6...v7.5.7) --- updated-dependencies: - dependency-name: tar dependency-version: 7.5.7 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 35d03c74fa..647b9d534e 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.6", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz", - "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==", + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { From 2727bec929bbf8565c9ba93aea991d821c84ceb4 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Fri, 30 Jan 2026 10:24:53 +0100 Subject: [PATCH 23/60] Reorder representation_template to improve clarity and organization as per user feedback --- src/bonsai/bonsai/bim/module/root/data.py | 86 +++++++++++++---------- 1 file changed, 48 insertions(+), 38 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/root/data.py b/src/bonsai/bonsai/bim/module/root/data.py index c23ea49e33..50f3248d8f 100644 --- a/src/bonsai/bonsai/bim/module/root/data.py +++ b/src/bonsai/bonsai/bim/module/root/data.py @@ -134,44 +134,8 @@ class IfcClassData: return [("FACE", "Face", "A planar face surface")] templates = [ ("EMPTY", "No Geometry", "Start with an empty object"), - None, - ( - "OBJ", - "Tessellation From Object", - "Use an object as a template to create a new tessellation", - ), - ( - "MESH", - "Custom Tessellation", - "Create a basic tessellated or faceted cube", - ), - ( - "EXTRUSION", - "Custom Extruded Solid", - "An extrusion from an arbitrary profile", - ), ] - if ifc_class.endswith("Type") or ifc_class.endswith("Style"): - templates.extend( - [ - None, - ( - "LAYERSET_AXIS2", - "Vertical Layers", - "For objects similar to walls, will automatically add IfcMaterialLayerSet", - ), - ( - "LAYERSET_AXIS3", - "Horizontal Layers", - "For objects similar to slabs, will automatically add IfcMaterialLayerSet", - ), - ( - "PROFILESET", - "Extruded Profile", - "Create profile type object, automatically defines IfcMaterialProfileSet with the first profile from library", - ), - ] - ) + if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"): templates.extend([None, ("WINDOW", "Window", "Parametric window")]) elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"): @@ -206,6 +170,11 @@ class IfcClassData: "Circular Hollow Distribution Segment", "Works similarly to Profile, has distribution ports", ), + ) + ) + if (ifc_class and "IfcCableCarrierSegment" in ifc_class): + templates.extend( + ( ( "FLOW_SEGMENT_U_SHAPE", "U-Shape Distribution Segment", @@ -213,7 +182,48 @@ class IfcClassData: ), ) ) - + + if ifc_class.endswith("Type") or ifc_class.endswith("Style"): + templates.extend( + [ + None, + ( + "LAYERSET_AXIS2", + "Vertical Layers", + "For objects similar to walls, will automatically add IfcMaterialLayerSet", + ), + ( + "LAYERSET_AXIS3", + "Horizontal Layers", + "For objects similar to slabs, will automatically add IfcMaterialLayerSet", + ), + ( + "PROFILESET", + "Extruded Profile", + "Create profile type object, automatically defines IfcMaterialProfileSet with the first profile from library", + ), + ] + ) + templates.extend( + [ + None, + ( + "OBJ", + "Tessellation From Object", + "Use an object as a template to create a new tessellation", + ), + ( + "MESH", + "Custom Tessellation", + "Create a basic tessellated or faceted cube", + ), + ( + "EXTRUSION", + "Custom Extruded Solid", + "An extrusion from an arbitrary profile", + ), + ] + ) return templates @classmethod From 6a63c651d374fe37300ec8950e02a7b11cb91d3f Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 30 Jan 2026 15:35:28 +0500 Subject: [PATCH 24/60] black . --- src/bonsai/bonsai/bim/module/model/array.py | 2 +- src/bonsai/bonsai/tool/model.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index b1c13dbe75..5f045435ac 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -204,7 +204,7 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator): if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)): tool.Geometry.delete_ifc_object(child_obj) array["children"].clear() - print('cleared array', arrays) + print("cleared array", arrays) tool.Model.regenerate_array(obj, arrays) tool.Blender.Modifier.Array.constrain_children_to_parent(element) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 636ec9bc73..148f4a6b75 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -1067,7 +1067,9 @@ class Model(bonsai.core.tool.Model): parent_element = tool.Ifc.get_entity(parent_obj) if pset := ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array"): - ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=parent_element, pset=tool.Ifc.get().by_id(pset["id"])) + ifcopenshell.api.pset.remove_pset( + tool.Ifc.get(), product=parent_element, pset=tool.Ifc.get().by_id(pset["id"]) + ) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) obj_stack = [parent_obj] @@ -1104,7 +1106,9 @@ class Model(bonsai.core.tool.Model): # add child pset if not (child_pset := tool.Pset.get_element_pset(child_element, "BBIM_Array")): - child_pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=child_element, name="BBIM_Array") + child_pset = ifcopenshell.api.pset.add_pset( + tool.Ifc.get(), product=child_element, name="BBIM_Array" + ) ifcopenshell.api.pset.edit_pset( tool.Ifc.get(), pset=child_pset, @@ -1148,7 +1152,9 @@ class Model(bonsai.core.tool.Model): pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=parent_element, name="BBIM_Array") json_data = tool.Ifc.get().createIfcText(json.dumps(data)) - ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}) + ifcopenshell.api.pset.edit_pset( + tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId} + ) @classmethod def replace_object_ifc_representation( From 47ac909a81047c41948228687d6e1d008b39df31 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 30 Jan 2026 15:37:12 +0500 Subject: [PATCH 25/60] ruff - sort imports --- src/bonsai/bonsai/bim/module/model/slab.py | 8 +++++--- src/bonsai/bonsai/bim/module/model/wall.py | 2 -- src/bonsai/test/core/test_type.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 3dcbf2aef0..602c36df11 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -40,9 +40,11 @@ import bonsai.core.root import bonsai.core.type import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from math import cos, pi -from mathutils import Vector, Matrix -from bonsai.bim.module.model.decorator import ProfileDecorator, PolylineDecorator, ProductDecorator +from bonsai.bim.module.model.decorator import ( + PolylineDecorator, + ProductDecorator, + ProfileDecorator, +) from bonsai.bim.module.model.polyline import PolylineOperator diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index d98a81f62d..ba3d93586b 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -48,8 +48,6 @@ import bonsai.core.root import bonsai.core.type import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from math import pi, sin, cos, degrees, atan2 -from mathutils import Vector, Matrix from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator from bonsai.bim.module.model.opening import FilledOpeningGenerator from bonsai.bim.module.model.polyline import PolylineOperator diff --git a/src/bonsai/test/core/test_type.py b/src/bonsai/test/core/test_type.py index 0987ab3d9e..e46cf8168f 100644 --- a/src/bonsai/test/core/test_type.py +++ b/src/bonsai/test/core/test_type.py @@ -17,7 +17,7 @@ # along with Bonsai. If not, see . import bonsai.core.type as subject -from test.core.bootstrap import ifc, model, type, geometry +from test.core.bootstrap import geometry, ifc, model, type class TestAssignType: From 38f9f309e78cd244ca30a689689bfe2d82f68cfc Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 26 Jan 2026 16:04:56 +0500 Subject: [PATCH 26/60] Bonsai Makefile - enable Python 3.13 build --- src/bonsai/Makefile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 68433466bd..e7ad75a1b4 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -61,6 +61,11 @@ PYLIBDIR:=python3.12 PYNUMBER:=312 PYPI_VERSION:=3.12 endif +ifeq ($(PYVERSION), py313) +PYLIBDIR:=python3.13 +PYNUMBER:=313 +PYPI_VERSION:=3.13 +endif ifeq ($(PLATFORM), linux) PYPI_PLATFORM:=--platform manylinux_2_17_x86_64 From e8a4590d0afe1d0117d7618c0bde4a39ace9a10a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 27 Jan 2026 13:33:56 +0500 Subject: [PATCH 27/60] Bonsai Makefile - error for missing PYVERSION --- src/bonsai/Makefile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index e7ad75a1b4..c265097d3e 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -48,7 +48,6 @@ VERSION_PATCH:=$(shell cat '../../VERSION' | cut -d '.' -f 3) VERSION_DATE:=$(shell date '+%y%m%d') LAST_COMMIT_HASH:=$(shell git rev-parse HEAD) LAST_COMMIT_DATE:=$(shell git show -s --format=%cI) -PYVERSION:=py310 PYPI_IMP:=cp ifeq ($(PYVERSION), py311) @@ -104,7 +103,10 @@ endif .PHONY: dist dist: ifndef PLATFORM - $(error PLATFORM is not set) + $(error PLATFORM is not set. Example values: win, linux, macos, macosm1.) +endif +ifndef PYVERSION + $(error PYVERSION is not set. Example value - 'py313'. Supported Python versions - 3.11-3.13.) endif rm -rf build mkdir -p build From d74588246a34a49831666db58234146c1aec27fe Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 28 Jan 2026 15:23:35 +0500 Subject: [PATCH 28/60] bonsai Makefile - workaround for pyradiance in Python 3.13 Until https://github.com/LBNL-ETA/pyradiance/issues/53 is resolved. --- src/bonsai/Makefile | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index c265097d3e..3a1fbe564b 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -195,12 +195,23 @@ endif cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pytz --dest=./wheels cd build && . env/$(VENV_ACTIVATE) && $(PIP) download tzfpy $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels # pyradiance is using different platform versions than defaults in our makefile. + # Temporary use `pyradiance_py313` until https://github.com/LBNL-ETA/pyradiance/issues/53 is resolved. +ifeq ($(PYVERSION), py313) +ifeq ($(PLATFORM), linux) + cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance_py313 --platform manylinux_2_35_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels +else ifeq ($(PLATFORM), macos) + cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance_py313 --platform macosx_10_13_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels +else + cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance_py313 $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels +endif +else ifeq ($(PLATFORM), linux) cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform manylinux_2_35_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels else ifeq ($(PLATFORM), macos) cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform macosx_10_13_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels else cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels +endif endif # Required by ifctester web ui. cd build && . env/$(VENV_ACTIVATE) && $(PIP) download flask $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels From ac4ffd91e7ced815737d6f893107a0cb2f159cce Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 28 Jan 2026 15:23:46 +0500 Subject: [PATCH 29/60] Bonsai Makefile - drop wheel renaming workaround #5743 --- src/bonsai/Makefile | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 3a1fbe564b..ca33bd4525 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -306,12 +306,6 @@ endif fi mv build/wheels/*.whl build/bonsai/wheels/ - # Temporary workaround for Blender not handling non-3.11 wheels #5743. - # Use '-n' as on macos x86-64 doesn't have a binary wheel and it autoincludes 'cp311'. - prev_whl_name=$$(find build/bonsai/wheels/tzfpy-*.whl); \ - whl_name=$$(echo $$prev_whl_name | sed "s/-cp39-/-cp$(PYNUMBER)-/"); \ - mv --update=none "$$prev_whl_name" "$$whl_name"; - ifneq ($(PLATFORM), linux) # Safeguard: in case one of `pip download` will break, # it will produce a linux wheel for non-linux build (our github action machine is using linux). From 91ee2b92220d86e711d217f36b6d7b2999eb78ca Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 28 Jan 2026 16:42:29 +0500 Subject: [PATCH 30/60] bonsai Makefile - require Blender 5.1 for Python 3.13 --- src/bonsai/Makefile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index ca33bd4525..03c05f2cbc 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -285,6 +285,11 @@ else $(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' build/pyproject.toml endif +# Blender 5.1+ requires Python 3.13. +ifeq ($(PYVERSION), py313) + $(SED) 's/blender_version_min = "4\.2\.0"/blender_version_min = "5.1.0"/' build/bonsai/blender_manifest.toml +endif + $(SED) "s/os-arch/$(BLENDER_PLATFORM)/" build/bonsai/blender_manifest.toml # Provides bonsai Add-on functionality From 40e4894f8e2087b31bf88ee29462f8e0b23a7091 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 28 Jan 2026 17:15:06 +0500 Subject: [PATCH 31/60] ci-bonsai-daily - exclude intel mac build for Python 3.13 Since Blender dropped support for it in Blender 5.0 and Python 3.13 is only needed for Blender 5.1. --- .github/workflows/ci-bonsai-daily.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml index 8beddfe668..3b52198f3a 100644 --- a/.github/workflows/ci-bonsai-daily.yml +++ b/.github/workflows/ci-bonsai-daily.yml @@ -53,6 +53,11 @@ jobs: name: "MacOS ARM Build", short_name: macosm1, } + exclude: + # Python 3.13 is needed for Blender 5.1+ and Blender dropped Intel Mac support in 5.0. + - pyver: py313 + config: + short_name: macos steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 # https://github.com/actions/setup-python From 2f7234684571db6b06359ae2c3bb9a5bdc466dfd Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 29 Jan 2026 12:23:44 +0500 Subject: [PATCH 32/60] bonsai Makefile - fix platform tag to use the latest pyradiance Apparently `pip download` is matching tag exactly, which was leading to Linux using pyradiance 0.5.3 instead of 1.1.5. --- src/bonsai/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 03c05f2cbc..aabf86cf4d 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -198,7 +198,7 @@ endif # Temporary use `pyradiance_py313` until https://github.com/LBNL-ETA/pyradiance/issues/53 is resolved. ifeq ($(PYVERSION), py313) ifeq ($(PLATFORM), linux) - cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance_py313 --platform manylinux_2_35_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels + cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance_py313 --platform manylinux_2_28_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels else ifeq ($(PLATFORM), macos) cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance_py313 --platform macosx_10_13_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels else @@ -206,7 +206,7 @@ else endif else ifeq ($(PLATFORM), linux) - cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform manylinux_2_35_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels + cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform manylinux_2_28_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels else ifeq ($(PLATFORM), macos) cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform macosx_10_13_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels else From 35dc0298988b1dc8a5424487b32f86e1154c54ca Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 29 Jan 2026 17:40:57 +0500 Subject: [PATCH 33/60] bonsai - add a dockerfile --- src/bonsai/Dockerfile | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/bonsai/Dockerfile diff --git a/src/bonsai/Dockerfile b/src/bonsai/Dockerfile new file mode 100644 index 0000000000..9805ec0530 --- /dev/null +++ b/src/bonsai/Dockerfile @@ -0,0 +1,22 @@ +# Build Bonsai .zip. +# Usage: +# # While in `bonsai` folder. +# docker build -t bonsai-dist . +# # Output zip file will be in `dist` folder. +# # See possible values for variables in `Makefile`'s `dist` target description. +# docker run --rm -v "$PWD/../../:/work" -w /work -e PLATFORM=win -e PYVERSION=py313 bonsai-dist + +FROM ubuntu:24.04 +RUN apt-get update +RUN apt-get install -y make git wget unzip zip + +# Python +RUN apt-get install -y software-properties-common +RUN add-apt-repository ppa:deadsnakes/ppa +RUN apt-get update +RUN apt-get install -y python3.11 python3.11-venv + +RUN apt-get install -y npm + +WORKDIR /work +CMD cd src/bonsai && make dist PLATFORM=$PLATFORM PYVERSION=$PYVERSION From 6409f41cdf2f608946d52f26460529cfc46fa88e Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 30 Jan 2026 16:02:32 +0500 Subject: [PATCH 34/60] Bonsai Makefile - sort of simplify pyversion check --- src/bonsai/Makefile | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index aabf86cf4d..133920bd00 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -50,20 +50,31 @@ LAST_COMMIT_HASH:=$(shell git rev-parse HEAD) LAST_COMMIT_DATE:=$(shell git show -s --format=%cI) PYPI_IMP:=cp -ifeq ($(PYVERSION), py311) -PYLIBDIR:=python3.11 -PYNUMBER:=311 -PYPI_VERSION:=3.11 +ifdef PYVERSION +SUPPORTED_PYVERSIONS := py311 py312 py313 + +ifeq ($(filter $(PYVERSION),$(SUPPORTED_PYVERSIONS)),) +$(error Unsupported PYVERSION=$(PYVERSION). Must be one of $(SUPPORTED_PYVERSIONS)) endif -ifeq ($(PYVERSION), py312) -PYLIBDIR:=python3.12 -PYNUMBER:=312 -PYPI_VERSION:=3.12 + +PYMINOR:=$(subst py3,,$(PYVERSION)) +PYLIBDIR:=python3.$(PYMINOR) +PYNUMBER:=3$(PYMINOR) +PYPI_VERSION:=3.$(PYMINOR) +endif # def PYVERSION + + +ifdef PLATFORM +SUPPORTED_PLATFORMS := linux macos macosm1 win + +ifeq ($(filter $(PLATFORM),$(SUPPORTED_PLATFORMS)),) +$(error Unsupported PLATFORM=$(PLATFORM). Must be one of $(SUPPORTED_PLATFORMS)) +endif + +ifeq ($(PLATFORM),macos) +ifeq ($(PYVERSION),py313) +$(error Blender 5.1 with Python 3.13 doesn't support intel macOS.) endif -ifeq ($(PYVERSION), py313) -PYLIBDIR:=python3.13 -PYNUMBER:=313 -PYPI_VERSION:=3.13 endif ifeq ($(PLATFORM), linux) @@ -90,6 +101,8 @@ PYPI_PLATFORM:=--platform win_amd64 BLENDER_PLATFORM:=windows-x64 endif +endif # def PLATFORM + # Current build commit hash. OLD:=e8eb5e4 .PHONY: bump @@ -103,10 +116,10 @@ endif .PHONY: dist dist: ifndef PLATFORM - $(error PLATFORM is not set. Example values: win, linux, macos, macosm1.) + $(error PLATFORM is not set. Example values: $(SUPPORTED_PLATFORMS).) endif ifndef PYVERSION - $(error PYVERSION is not set. Example value - 'py313'. Supported Python versions - 3.11-3.13.) + $(error PYVERSION is not set. Example values: $(SUPPORTED_PYVERSIONS). ) endif rm -rf build mkdir -p build From 611a898ada157b9d5cb9b5081b96b12405581da5 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 30 Jan 2026 20:07:04 +0000 Subject: [PATCH 35/60] IFC Git close and reopen repo after cloning Apparently on Windows clone can leave 'stale processes' --- src/bonsai/bonsai/tool/ifcgit.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 6854b38432..db557542bc 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -64,10 +64,13 @@ class IfcGit: @classmethod def clone_repo(cls, remote_url: str, local_folder: str) -> git.Repo: - IfcGitRepo.repo = git.Repo.clone_from( + repo = git.Repo.clone_from( url=remote_url, to_path=local_folder, ) + # Close the repo to release stale subprocess + repo.close() + IfcGitRepo.repo = git.Repo(local_folder) cls.config_info_attributes(IfcGitRepo.repo) return IfcGitRepo.repo From 9b8b05a570223d7e1f70380d5385299912239577 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:45:49 +0000 Subject: [PATCH 36/60] Bump gersemi from 0.25.1 to 0.25.4 Bumps [gersemi](https://github.com/BlankSpruce/gersemi) from 0.25.1 to 0.25.4. - [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.25.1...0.25.4) --- updated-dependencies: - dependency-name: gersemi dependency-version: 0.25.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 13f3b15ba6..4e9d9687c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ dependencies = [ "black==26.1.0", "ruff==0.14.14", "poethepoet", - "gersemi==0.25.1", + "gersemi==0.25.4", ] [tool.black] From c6257a4b3ac9f7d0b8a09272bc9835a4eaee23e8 Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Sat, 31 Jan 2026 05:30:06 -0800 Subject: [PATCH 37/60] Fixes errors in ifc_curve_rebar.cpp M_PI was not defined,. Replaced it with boost::math::constants::pi Calculation of area was incorrect. Area of a circle = PI*r*r = PI*dia*dia/4 --- src/examples/ifc_curve_rebar.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/examples/ifc_curve_rebar.cpp b/src/examples/ifc_curve_rebar.cpp index 81a705dc5e..0f66f7b45a 100644 --- a/src/examples/ifc_curve_rebar.cpp +++ b/src/examples/ifc_curve_rebar.cpp @@ -31,6 +31,9 @@ #include "ifcparse/Ifc2x3.h" #include "ifcparse/IfcHierarchyHelper.h" +#include +const static double PI = boost::math::constants::pi(); + typedef std::string S; typedef IfcParse::IfcGlobalId guid; boost::none_t const null = boost::none; @@ -41,7 +44,7 @@ void create_curve_rebar(IfcHierarchyHelper& file) int R = 3 * dia; int length = 12 * dia; - double crossSectionarea = M_PI * (dia / 2) * 2; + double crossSectionarea = PI * dia * dia / 4; IfcSchema::IfcReinforcingBar* rebar = new IfcSchema::IfcReinforcingBar( guid(), 0, S("test"), null, null, 0, 0, From c385b93701fd7e4441be213280b04c45723577fc Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sun, 1 Feb 2026 21:08:11 +0100 Subject: [PATCH 38/60] Add option --make-volume to apply BOPAlgo_MakerVolume API to non-manifold first operands in opening subtraction --- src/ifcgeom/ConversionSettings.h | 8 +++++++- .../kernels/opencascade/OpenCascadeKernel.cpp | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/ifcgeom/ConversionSettings.h b/src/ifcgeom/ConversionSettings.h index eb405a9e7c..febd789f6b 100644 --- a/src/ifcgeom/ConversionSettings.h +++ b/src/ifcgeom/ConversionSettings.h @@ -452,6 +452,12 @@ namespace ifcopenshell { static constexpr bool defaultvalue = false; }; + struct MakeVolume : public SettingBase { + static constexpr const char* const name = "make-volume"; + static constexpr const char* const description = "Try to isolate and fix a valid volume from non-manifold elements prior to opening subtraction"; + static constexpr bool defaultvalue = false; + }; + struct DeferProcessingFirstElement : public SettingBase { static constexpr const char* const name = "defer-processing-first-element"; static constexpr const char* const description = "Don't process first element in Iterator::initialize call()"; @@ -647,7 +653,7 @@ namespace ifcopenshell { }; class Settings : public SettingsContainer< - std::tuple + std::tuple > {}; } diff --git a/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp b/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp index fcb390b6cc..a5a9d94149 100644 --- a/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp +++ b/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp @@ -29,6 +29,7 @@ #include "base_utils.h" #include +#include namespace { struct opening_sorter { @@ -132,11 +133,24 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity* parts.push_back(it3_shape); } - for (auto& entity_part : parts) { + for (auto entity_part : parts) { bool is_manifold = util::is_manifold(entity_part); if (!is_manifold) { - Logger::Warning("Non-manifold first operand"); + if (settings_.get().get()) { + BOPAlgo_MakerVolume mv; + mv.AddArgument(entity_part); + mv.SetAvoidInternalShapes(true); + try { + mv.Perform(); + entity_part = mv.Shape(); + Logger::Warning("Sucessfully detected exterior volume to non-manifold first operand"); + } catch (const Standard_Failure& e) { + Logger::Warning("MakeVolume failed: " + std::string(e.GetMessageString()), entity); + } + } else { + Logger::Warning("Non-manifold first operand, use --make-volume to try and make manifold"); + } } TopoDS_Shape entity_part_result; From 950e147bdef9b170cc1952c0394314a231762e63 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Mon, 2 Feb 2026 09:34:28 -0600 Subject: [PATCH 39/60] fixes #7629: close editing mode for text objects after batch edit Fix issue where selected text annotations remained in editing mode after applying changes. Now properly restores original editing state for each selected object. --- .../bonsai/bim/module/drawing/operator.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 8c29bfbf41..a19a972ad6 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3294,6 +3294,15 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator): """Apply changes to other selected text objects using captured apply settings""" selected_objects = [obj for obj in context.selected_objects if obj != active_obj] + # Track editing status ONLY for selected objects (not all visible objects) + editing_status = {} + for obj in selected_objects: + element = tool.Ifc.get_entity(obj) + if not element or not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]): + continue + obj_props = tool.Drawing.get_text_props(obj) + editing_status[obj] = obj_props.is_editing if hasattr(obj_props, "is_editing") else False + for obj in selected_objects: element = tool.Ifc.get_entity(obj) if not element: @@ -3345,6 +3354,16 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator): if needs_update: core.edit_text(tool.Drawing, obj=obj) + # Restore original editing state for all selected objects + for obj, was_editing in editing_status.items(): + obj_props = tool.Drawing.get_text_props(obj) + if hasattr(obj_props, "is_editing"): + # If object was NOT originally being edited, disable editing mode now + if not was_editing and obj_props.is_editing: + core.disable_editing_text(tool.Drawing, obj=obj) + # If object WAS originally being edited but isn't now, re-enable it + elif was_editing and not obj_props.is_editing: + core.enable_editing_text(tool.Drawing, obj=obj) class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_text" From fe395f9024f63bffd18d0abbb8613cf4186b5778 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 3 Feb 2026 09:13:26 +1100 Subject: [PATCH 40/60] Revert "fixes #7629: close editing mode for text objects after batch edit" This reverts commit 950e147bdef9b170cc1952c0394314a231762e63. --- .../bonsai/bim/module/drawing/operator.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index a19a972ad6..8c29bfbf41 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3294,15 +3294,6 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator): """Apply changes to other selected text objects using captured apply settings""" selected_objects = [obj for obj in context.selected_objects if obj != active_obj] - # Track editing status ONLY for selected objects (not all visible objects) - editing_status = {} - for obj in selected_objects: - element = tool.Ifc.get_entity(obj) - if not element or not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]): - continue - obj_props = tool.Drawing.get_text_props(obj) - editing_status[obj] = obj_props.is_editing if hasattr(obj_props, "is_editing") else False - for obj in selected_objects: element = tool.Ifc.get_entity(obj) if not element: @@ -3354,16 +3345,6 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator): if needs_update: core.edit_text(tool.Drawing, obj=obj) - # Restore original editing state for all selected objects - for obj, was_editing in editing_status.items(): - obj_props = tool.Drawing.get_text_props(obj) - if hasattr(obj_props, "is_editing"): - # If object was NOT originally being edited, disable editing mode now - if not was_editing and obj_props.is_editing: - core.disable_editing_text(tool.Drawing, obj=obj) - # If object WAS originally being edited but isn't now, re-enable it - elif was_editing and not obj_props.is_editing: - core.enable_editing_text(tool.Drawing, obj=obj) class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_text" From 1445f89f19e7ca41eab1408fd349d919519b0378 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 3 Feb 2026 18:09:15 +1100 Subject: [PATCH 41/60] See #7629. Partially reimplement bulk text editing. This isn't complete yet, but it hopefully demonstrates a preferred implementation: * Logic in core, not operator * Loop done in core, without needing to call other core functions, so the overhead of enabling and disabling editing per object is removed. No more Blender logic, just straight editing in IFC. * Reuse existing function to grab text attributes instead of reimplementing it twice. * Remove dead code, there seems to be a function apply_to_selected_objects which was completely unused and duplicated code twice. --- .../bonsai/bim/module/drawing/operator.py | 165 +----------------- src/bonsai/bonsai/core/drawing.py | 13 +- src/bonsai/bonsai/core/tool.py | 1 - src/bonsai/bonsai/tool/drawing.py | 39 +---- 4 files changed, 19 insertions(+), 199 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 8c29bfbf41..4c52993aa0 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3185,166 +3185,15 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - obj = context.active_object - props = tool.Drawing.get_text_props(obj) - - captured_apply_settings = { - "apply_font_size_to_all": props.apply_font_size_to_all, - "apply_newline_to_all": props.apply_newline_to_all, - "font_size": props.font_size, - "newline_at": props.newline_at, - "literals": [], - } - - for i, literal in enumerate(props.literals): - literal_data = { - "attributes": [ - (attr.string_value, attr.enum_value if attr.data_type == "enum" else attr.string_value) - for attr in literal.attributes - ], - "box_alignment": literal.box_alignment[:] if hasattr(literal, "box_alignment") else None, - "element_value_rows": [ - { - "category": row.category, - "element_key": row.element_key, - "formatted_value": row.formatted_value, - "separator": row.separator, - } - for row in literal.element_value_rows - ], - "product_used": literal.product_used.name if literal.product_used else None, - } - - if i < len(props.literal_apply_settings): - apply_settings = props.literal_apply_settings[i] - literal_data["apply_text_to_all"] = apply_settings.apply_text_to_all - literal_data["apply_path_to_all"] = apply_settings.apply_path_to_all - literal_data["apply_box_alignment_to_all"] = apply_settings.apply_box_alignment_to_all - else: - literal_data["apply_text_to_all"] = False - literal_data["apply_path_to_all"] = False - literal_data["apply_box_alignment_to_all"] = False - - captured_apply_settings["literals"].append(literal_data) - - obj["_bonsai_element_value_rows_backup"] = json.dumps(captured_apply_settings["literals"]) - - core.edit_text(tool.Drawing, obj=obj) - - self.apply_to_selected_objects_with_captured_data(context, obj, captured_apply_settings) - + apply_objs = [ + obj + for obj in tool.Blender.get_selected_objects() + if (element := tool.Ifc.get_entity(obj)) + and tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]) + ] + core.edit_text(tool.Drawing, attribute_obj=tool.Blender.get_active_object(), apply_objs=apply_objs) tool.Blender.update_viewport() - return {"FINISHED"} - - def apply_to_selected_objects(self, context, active_obj, active_props): - """Apply changes to other selected text objects based on toggle settings""" - selected_objects = [obj for obj in context.selected_objects if obj != active_obj] - - for obj in selected_objects: - element = tool.Ifc.get_entity(obj) - if not element or not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]): - continue - - obj_props = tool.Drawing.get_text_props(obj) - needs_update = False - - if active_props.apply_font_size_to_all: - obj_props.font_size = active_props.font_size - needs_update = True - - if active_props.apply_newline_to_all: - obj_props.newline_at = active_props.newline_at - needs_update = True - - for i, active_literal in enumerate(active_props.literals): - if i >= len(obj_props.literals): - continue - - obj_props.ensure_literal_apply_settings(len(obj_props.literals)) - obj_literal = obj_props.literals[i] - - if i < len(active_props.literal_apply_settings): - active_settings = active_props.literal_apply_settings[i] - - if active_settings.apply_text_to_all: - if len(active_literal.attributes) > 0 and len(obj_literal.attributes) > 0: - obj_literal.attributes[0].string_value = active_literal.attributes[0].string_value - needs_update = True - - if active_settings.apply_path_to_all: - if len(active_literal.attributes) > 1 and len(obj_literal.attributes) > 1: - if ( - active_literal.attributes[1].data_type == "enum" - and obj_literal.attributes[1].data_type == "enum" - ): - obj_literal.attributes[1].enum_value = active_literal.attributes[1].enum_value - else: - obj_literal.attributes[1].string_value = active_literal.attributes[1].string_value - needs_update = True - - if active_settings.apply_box_alignment_to_all: - obj_literal.box_alignment = active_literal.box_alignment[:] - needs_update = True - - if needs_update: - core.edit_text(tool.Drawing, obj=obj) - - def apply_to_selected_objects_with_captured_data(self, context, active_obj, captured_data): - """Apply changes to other selected text objects using captured apply settings""" - selected_objects = [obj for obj in context.selected_objects if obj != active_obj] - - for obj in selected_objects: - element = tool.Ifc.get_entity(obj) - if not element: - continue - if not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]): - continue - - obj_props = tool.Drawing.get_text_props(obj) - - if len(obj_props.literals) == 0: - core.enable_editing_text(tool.Drawing, obj=obj) - obj_props.ensure_literal_apply_settings(len(obj_props.literals)) - - needs_update = False - - if captured_data["apply_font_size_to_all"]: - obj_props.font_size = captured_data["font_size"] - needs_update = True - - if captured_data["apply_newline_to_all"]: - obj_props.newline_at = captured_data["newline_at"] - needs_update = True - - for i, captured_literal in enumerate(captured_data["literals"]): - if i >= len(obj_props.literals): - continue - - obj_literal = obj_props.literals[i] - - if captured_literal["apply_text_to_all"]: - if len(captured_literal["attributes"]) > 0 and len(obj_literal.attributes) > 0: - new_value = captured_literal["attributes"][0][0] # [0] = string_value - obj_literal.attributes[0].string_value = new_value - needs_update = True - - if captured_literal["apply_path_to_all"]: - if len(captured_literal["attributes"]) > 1 and len(obj_literal.attributes) > 1: - new_value = captured_literal["attributes"][1][1] # [1] = enum_value or string_value - if obj_literal.attributes[1].data_type == "enum": - obj_literal.attributes[1].enum_value = new_value - else: - obj_literal.attributes[1].string_value = new_value - needs_update = True - - if captured_literal["apply_box_alignment_to_all"] and captured_literal["box_alignment"]: - obj_literal.box_alignment = captured_literal["box_alignment"] - needs_update = True - - if needs_update: - core.edit_text(tool.Drawing, obj=obj) - class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_text" diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index b56b51751f..eb2c47f1dc 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -38,11 +38,14 @@ def disable_editing_text(drawing: type[tool.Drawing], obj: bpy.types.Object) -> drawing.disable_editing_text(obj) -def edit_text(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None: - drawing.synchronise_ifc_and_text_attributes(obj) - drawing.update_text_size_pset(obj) - drawing.update_text_annotation_properties(obj) - drawing.disable_editing_text(obj) +def edit_text(drawing: type[tool.Drawing], attribute_obj: bpy.types.Object, apply_objs: list[bpy.types.Object]) -> None: + literal_attributes = drawing.export_text_literal_attributes(attribute_obj) + for obj in apply_objs: + drawing.edit_text_literals(obj, literal_attributes) + # TODO: font size should be part of a separate set of formatting controls, not part of text editing + drawing.update_text_size_pset(obj) + drawing.update_text_annotation_properties(obj) + drawing.disable_editing_text(obj) def enable_editing_assigned_product(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None: diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 19395fa449..a34de4d4fe 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -402,7 +402,6 @@ class Drawing: def setup_shading_styles_path(cls, resource_path): pass def show_decorations(cls): pass def sync_object_placement(cls, obj): pass - def synchronise_ifc_and_text_attributes(cls, obj): pass def update_embedded_svg_location(cls, uri, old_location, new_location): pass def update_text_annotation_properties(cls, obj): pass def update_text_size_pset(cls, obj): pass diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 51d06c1c18..a0a45b403a 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -835,43 +835,12 @@ class Drawing(bonsai.core.tool.Drawing): return props.is_editing_sheets @classmethod - def synchronise_ifc_and_text_attributes(cls, obj: bpy.types.Object) -> None: + def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None: assert (element := tool.Ifc.get_entity(obj)) assert (rep := cls.get_annotation_representation(element)) - - old_literals = cls.get_text_literal(obj, return_list=True) - assert isinstance(old_literals, list) - literals_attributes = cls.export_text_literal_attributes(obj) - props = cls.get_text_props(obj) - defined_ifc_ids = [l.ifc_definition_id for l in props.literals] - ifc_file = tool.Ifc.get() - - added_literals: list[ifcopenshell.entity_instance] = [] - new_literals: list[ifcopenshell.entity_instance] = [] - for ifc_definition_id, attributes in zip(defined_ifc_ids, literals_attributes): - # making sure all literals from text edit exist in ifc - if ifc_definition_id == 0: - literal = cls.add_literal(**attributes) - added_literals.append(literal) - else: - literal = ifc_file.by_id(ifc_definition_id) - ifcopenshell.api.drawing.edit_text_literal( - ifc_file, - text_literal=literal, - attributes=attributes, - ) - new_literals.append(literal) - - removed_literals = set(old_literals) - set(new_literals) - - # Add new literals and keep the order as defined in text props. - items = [i for i in rep.Items if i not in removed_literals] + added_literals - items.sort(key=lambda x: new_literals.index(x) if x in new_literals else -1) - rep.Items = items - - # Remove from ifc the literals that were removed during the edit. - for literal in removed_literals: - ifcopenshell.util.element.remove_deep2(ifc_file, literal) + for literal in cls.get_text_literal(obj, return_list=True): + ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), literal) + rep.Items = [cls.add_literal(**a) for a in literal_attributes] @classmethod def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance: From fdf23ece8576a14473a462713243f84dfbc0ff73 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 3 Feb 2026 18:20:49 +1100 Subject: [PATCH 42/60] Fix #7632. See #2824. Optimise vertex matching trick for IfcIndexedColourMap. --- src/bonsai/bonsai/tool/loader.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index 8b14de9362..d81fec5fd2 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -39,6 +39,7 @@ import numpy as np import numpy.typing as npt from ifcopenshell.util.shape_builder import np_to_4d from mathutils import Matrix, Vector +from mathutils.kdtree import KDTree import bonsai.bim.import_ifc import bonsai.core.tool @@ -562,7 +563,18 @@ class Loader(bonsai.core.tool.Loader): bm_verts = np.array([v.co for v in bm.verts]) coords_scaled = np.array(faceset.Coordinates.CoordList) * si_conversion - coordinates_remap = [np.argmin(np.sum((bm_verts - co) ** 2, axis=1)) for co in coords_scaled] + # See #2824. IfcIndexedColourMap is not natively handled by IfcOpenShell + # As a result, we map IFC coords to Blender coords (highly wasteful but...) + # coordinates_remap = [np.argmin(np.sum((bm_verts - co) ** 2, axis=1)) for co in coords_scaled] + # Because this is O(N*M), here is a faster KDTree implementation. + kd = KDTree(len(bm_verts)) + for i, v in enumerate(bm_verts): + kd.insert((float(v[0]), float(v[1]), float(v[2])), i) + kd.balance() + coordinates_remap = np.empty(len(coords_scaled), dtype=np.int32) + for j, co in enumerate(coords_scaled): + _co, index, _dist = kd.find((float(co[0]), float(co[1]), float(co[2]))) + coordinates_remap[j] = index # ifc indices start with 1 remap_verts_to_blender = lambda ifc_verts: [coordinates_remap[i - 1] for i in ifc_verts] From 9dff1441d7b61fa396fedd5f891521695ec37abd Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 3 Feb 2026 18:23:39 +1100 Subject: [PATCH 43/60] See #7632. See #5848. Just disable indexed colour map loading for now, it's pretty rare. Famous last words, at least the KDTree implementation is much faster. --- src/bonsai/bonsai/bim/module/project/prop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index ea5c9c843a..1c62c98d61 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -370,7 +370,7 @@ class BIMProjectProperties(PropertyGroup): load_indexed_maps: BoolProperty( name="Load Indexed Maps", description="Load indexed maps (UV and color maps)", - default=True, + default=False, # Very slow and hackishly implemented ) links: CollectionProperty(name="Links", type=Link) active_link_index: IntProperty(name="Active Link Index") From 95cc52a6f36ca230c3fd41646b5e281420e746c6 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 3 Feb 2026 12:28:37 +0500 Subject: [PATCH 44/60] bonsai - revive very important chaching sound `get_data_dir_path` doesn't have `filename` argument, therefore leading to an error --- src/bonsai/bonsai/tool/cost.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index 771b15f43e..06e3de6779 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -151,17 +151,14 @@ class Cost(bonsai.core.tool.Cost): # TODO: make pitch higher as costs rise try: import aud + except ImportError: + return # ah well - device = aud.Device() - # chaching.mp3 is by Lucish_ CC-BY-3.0 https://freesound.org/people/Lucish_/sounds/554841/ - sound = aud.Sound(tool.Blender.get_data_dir_path(filename="chaching.mp3").__str__()) - handle = device.play(sound) - sound_buffered = aud.Sound.buffer(sound) - handle_buffered = device.play(sound_buffered) - handle.stop() - handle_buffered.stop() - except: - pass # ah well + device = aud.Device() + # chaching.mp3 is by Lucish_ CC-BY-3.0 https://freesound.org/people/Lucish_/sounds/554841/ + filepath = tool.Blender.get_data_dir_path("chaching.mp3").__str__() + sound = aud.Sound(filepath) + device.play(sound) @classmethod def load_cost_schedule_tree(cls) -> None: From 015431fa1f2395c0fd6efbc148534de7c88a257c Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 3 Feb 2026 12:31:11 +0500 Subject: [PATCH 45/60] bonsai - drop unrelated pypi `aud` module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turned out `aud` module we had in our makefile had nothing to do with Blender built-in `uad` module 🫣 So no need to install anything from PyPI since this module is generally available in Blender --- .github/workflows/ci.yml | 1 - src/bonsai/Makefile | 3 --- src/bonsai/bonsai/tool/cost.py | 6 +----- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a2fe8ce5d..65031e83d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,6 @@ jobs: python -m pip install --upgrade pip pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pip install src/bcf --no-deps - pip install git+https://github.com/zdhoward/aud pip install pytest-xdist==3.8.0 - name: Install C++ dependencies diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 133920bd00..864d522927 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -145,9 +145,6 @@ endif cd ../ifc5d && make dist && mv dist/*.whl ../bonsai/build/wheels/ cd ../ifccityjson && make dist && mv dist/*.whl ../bonsai/build/wheels/ cd build && . env/$(VENV_ACTIVATE) && $(PIP) download GitPython --dest=./wheels - # Provides audio playback for costing - # This is a REALLY IMPORTANT feature - cd build && . env/$(VENV_ACTIVATE) && $(PIP) wheel git+https://github.com/zdhoward/aud --wheel-dir=./wheels # IfcOpenShell dependency - support for new typing features cd build && . env/$(VENV_ACTIVATE) && $(PIP) download typing_extensions --dest=./wheels # Required by IfcCSV diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index 06e3de6779..3dcbb8e32c 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -23,6 +23,7 @@ from collections.abc import Generator from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Optional, Union, assert_never +import aud import bpy import ifcopenshell.api import ifcopenshell.api.cost @@ -149,11 +150,6 @@ class Cost(bonsai.core.tool.Cost): @classmethod def play_chaching_sound(cls) -> None: # TODO: make pitch higher as costs rise - try: - import aud - except ImportError: - return # ah well - device = aud.Device() # chaching.mp3 is by Lucish_ CC-BY-3.0 https://freesound.org/people/Lucish_/sounds/554841/ filepath = tool.Blender.get_data_dir_path("chaching.mp3").__str__() From abf0d6f81a8cb678cb3065b9675cd64d7f27553b Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 3 Feb 2026 12:32:11 +0500 Subject: [PATCH 46/60] bonsai chaching - save ears for those running the tests --- src/bonsai/bonsai/tool/cost.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index 3dcbb8e32c..663b16040e 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -144,7 +144,8 @@ class Cost(bonsai.core.tool.Cost): @classmethod def play_sound(cls) -> None: - if tool.Blender.get_addon_preferences().should_play_chaching_sound: + # Save ears for those running the tests in background mode. + if tool.Blender.get_addon_preferences().should_play_chaching_sound and not bpy.app.background: cls.play_chaching_sound() # lol @classmethod From d6b636909f86b12e58aac14d873bd7b05a17bea9 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 3 Feb 2026 12:42:15 +0500 Subject: [PATCH 47/60] bonsai Makefile - revert d745882 Since https://github.com/LBNL-ETA/pyradiance/issues/56 is resolved now. --- src/bonsai/Makefile | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 864d522927..6b4704b7b3 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -205,23 +205,12 @@ endif cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pytz --dest=./wheels cd build && . env/$(VENV_ACTIVATE) && $(PIP) download tzfpy $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels # pyradiance is using different platform versions than defaults in our makefile. - # Temporary use `pyradiance_py313` until https://github.com/LBNL-ETA/pyradiance/issues/53 is resolved. -ifeq ($(PYVERSION), py313) -ifeq ($(PLATFORM), linux) - cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance_py313 --platform manylinux_2_28_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels -else ifeq ($(PLATFORM), macos) - cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance_py313 --platform macosx_10_13_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels -else - cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance_py313 $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels -endif -else ifeq ($(PLATFORM), linux) cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform manylinux_2_28_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels else ifeq ($(PLATFORM), macos) cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform macosx_10_13_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels else cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels -endif endif # Required by ifctester web ui. cd build && . env/$(VENV_ACTIVATE) && $(PIP) download flask $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels From a62b177b194735b6232fa56de53cf9cbd4f84448 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 3 Feb 2026 19:20:01 +0500 Subject: [PATCH 48/60] Bonsai - fix error drag'n'dropping ifc files in Blender 4.5.6 Example error: ``` Error: Couldn't find IFC file: 'L:/OD_Cool_Car_Guys.ifc'. Traceback (most recent call last): File "\Blender Foundation\Blender\4.5\extensions\.local\lib\python3.11\site-packages\bonsai\bim\module\project\operator.py", line 2759, in invoke return bpy.ops.bim.load_project(filepath=(filepath / clean_up_path(self.files[0].name)).as_posix()) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "\blender-4.5.6-lts.a78963ed6435\4.5\scripts\modules\bpy\ops.py", line 109, in __call__ ret = _op_call(self.idname_py(), kw) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RuntimeError: Error: Couldn't find IFC file: 'L:/OD_Cool_Car_Guys.ifc'. ``` --- src/bonsai/bonsai/bim/module/project/operator.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 8fad709de3..b6f63f13ca 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -2735,17 +2735,27 @@ class IFCFileHandlerOperator(bpy.types.Operator): # Keeping code in .invoke() as we'll probably add some # popup windows later. + def clean_up_path(path: str) -> str: + # In Blender 4.5.6 there was a bug producing unncesseary double slash prefix + # breaking the paths. Issue is not present in 5.0+ and presumably will be solved in 4.5.7 too. + # https://projects.blender.org/blender/blender/issues/153822 + if bpy.app.version == (4, 5, 6): + blender_prefix = "//" + if path.startswith(blender_prefix): + return path.removeprefix(blender_prefix) + return path + # `files` contain only .ifc files. filepath = Path(self.directory) # If user is just drag'n'dropping a single file -> load it as a new project, # if they're holding ALT -> link the file/files to the current project. if event.alt: # Passing self.files directly results in TypeError. - serialized_files = [{"name": f.name} for f in self.files] + serialized_files = [{"name": clean_up_path(f.name)} for f in self.files] return bpy.ops.bim.link_ifc(directory=self.directory, files=serialized_files) else: if len(self.files) == 1: - return bpy.ops.bim.load_project(filepath=(filepath / self.files[0].name).as_posix()) + return bpy.ops.bim.load_project(filepath=(filepath / clean_up_path(self.files[0].name)).as_posix()) else: self.report( {"INFO"}, From 95480a231c9f3404130207a31809a7235a242002 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 4 Feb 2026 18:00:01 +0500 Subject: [PATCH 49/60] Move handling legacy mathutils buffer types to separate methods --- .../bonsai/bim/module/drawing/decoration.py | 32 ++++++++----------- src/bonsai/bonsai/tool/blender.py | 31 +++++++++++++++++- src/bonsai/bonsai/tool/geometry.py | 5 ++- src/bonsai/bonsai/tool/ifc.py | 21 ++++-------- src/bonsai/test/tool/test_geometry.py | 4 +-- 5 files changed, 54 insertions(+), 39 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py index 0a6bc967bf..705fabfcf9 100644 --- a/src/bonsai/bonsai/bim/module/drawing/decoration.py +++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py @@ -1762,40 +1762,34 @@ class CutDecorator: shader.uniform_float("color", color) batch.draw(shader) - def cache_camera_matrix(self): + def cache_camera_matrix(self) -> None: + assert bpy.context.scene and bpy.context.scene.camera obj = bpy.context.scene.camera - # Explicit `dtype` for Blender <5.0 compatibility. DecoratorData.camera_location_checksum = repr( - np.array(obj.matrix_world.translation, dtype=np.float32).tobytes() + tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes() ) - DecoratorData.camera_rotation_checksum = repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes()) + DecoratorData.camera_rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes()) - def is_camera_moved(self): + def is_camera_moved(self) -> bool: if not DecoratorData.camera_location_checksum: self.cache_camera_matrix() return True # Let's be conservative + + assert bpy.context.scene and bpy.context.scene.camera obj = bpy.context.scene.camera # Handle both old float64 and new float32 checksums for version compatibility - loc_checksum_bytes = eval(DecoratorData.camera_location_checksum) - if len(loc_checksum_bytes) == 24: # Old format: 3 * 8 bytes (float64) - loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float64).astype(np.float32) - else: # New format: 3 * 4 bytes (float32) - loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float32) - - loc_real = np.array(obj.matrix_world.translation, dtype=np.float32).flatten() + loc_checksum_bytes: bytes = eval(DecoratorData.camera_location_checksum) + loc_check = tool.Blender.np_frombuffer_legacy(loc_checksum_bytes, 3) + loc_real = tool.Blender.np_array_legacy(obj.matrix_world.translation) if not np.allclose(loc_check, loc_real, atol=1e-4): # 0.1 mm self.cache_camera_matrix() return True # Handle both old float64 and new float32 checksums for version compatibility - rot_checksum_bytes = eval(DecoratorData.camera_rotation_checksum) - if len(rot_checksum_bytes) == 72: # Old format: 9 * 8 bytes (float64) - rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float64).astype(np.float32).reshape(3, 3) - else: # New format: 9 * 4 bytes (float32) - rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float32).reshape(3, 3) - - rot_real = np.array(obj.matrix_world.to_3x3(), dtype=np.float32) + rot_checksum_bytes: bytes = eval(DecoratorData.camera_rotation_checksum) + rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9) + rot_real = tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()) rot_dot = np.dot(rot_check, rot_real.T) angle_rad = np.arccos(np.clip((np.trace(rot_dot) - 1) / 2, -1, 1)) if angle_rad > 0.0017453292519943296: # 0.1 degrees diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index d4491e104a..b78772fbc5 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -49,7 +49,7 @@ import ifcopenshell.util.element import numpy as np import numpy.typing as npt from ifcopenshell import entity_instance -from mathutils import Vector +from mathutils import Matrix, Vector import bonsai.bim import bonsai.core.tool @@ -2127,3 +2127,32 @@ class Blender(bonsai.core.tool.Blender): if cls.BLENDER_5: return "BLENDER_EEVEE" return "BLENDER_EEVEE_NEXT" + + @classmethod + def np_frombuffer_legacy(cls, bytedata: bytes, n: int) -> npt.NDArray[np.float32]: + """ + Read ``n`` float values from ``bytedata``, regardless if they are stored as ``float32`` or ``float64``. + Needed to support .blend files saved in Blender <5.0.0. + Also allows to work with .blend files from 5.0.0+ in older Blender versions. + + In ``bpy.app.version >= 5.0.0`` ``mathutils`` transitioned to use ``float32`` buffer type, + while in previous version they were using ``float64``. + In some cases we are storing raw bytes (e.g. object transforms cheksums), so old .blend files + might still have ``float64`` data stored. + + See https://projects.blender.org/blender/blender/issues/149283 + """ + if len(bytedata) == (n * 2): + return np.frombuffer(bytedata, dtype=np.float64).astype(np.float32) + return np.frombuffer(bytedata, dtype=np.float32) + + @classmethod + def np_array_legacy(cls, mathutils_type: Union[Vector, Matrix]) -> npt.NDArray[np.float32]: + """ + Converts ``mathutils`` types to ``np.float32`` arrays, regardless of Blender version. + + See ``np_frombuffer_legacy`` for more details. + """ + if cls.BLENDER_5: + return np.array(mathutils_type) + return np.array(mathutils_type, dtype=np.float32) diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 5a6429adfe..13ec6695d2 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -1152,9 +1152,8 @@ class Geometry(bonsai.core.tool.Geometry): def record_object_position(cls, obj: bpy.types.Object) -> None: # These are recorded separately because they have different numerical tolerances props = tool.Blender.get_object_bim_props(obj) - # Explicit dtype for Blender <5.0 compatibility. - props.location_checksum = repr(np.array(obj.matrix_world.translation, dtype=np.float32).tobytes()) - props.rotation_checksum = repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes()) + props.location_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes()) + props.rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes()) @classmethod def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None: diff --git a/src/bonsai/bonsai/tool/ifc.py b/src/bonsai/bonsai/tool/ifc.py index 81c5202c19..8cbd2a112b 100644 --- a/src/bonsai/bonsai/tool/ifc.py +++ b/src/bonsai/bonsai/tool/ifc.py @@ -115,24 +115,17 @@ class Ifc(bonsai.core.tool.Ifc): return True # Let's be conservative # Handle both old float64 and new float32 checksums for version compatibility - loc_checksum_bytes = eval(oprops.location_checksum) - if len(loc_checksum_bytes) == 24: # Old format: 3 * 8 bytes (float64) - loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float64).astype(np.float32) - else: # New format: 3 * 4 bytes (float32) - loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float32) - - loc_real = np.array(obj.matrix_world.translation, dtype=np.float32).flatten() + loc_checksum_bytes: bytes = eval(oprops.location_checksum) + loc_check = tool.Blender.np_frombuffer_legacy(loc_checksum_bytes, 3) + loc_real = tool.Blender.np_array_legacy(obj.matrix_world.translation) if not np.allclose(loc_check, loc_real, atol=1e-4): # 0.1 mm return True # Handle both old float64 and new float32 checksums for version compatibility - rot_checksum_bytes = eval(oprops.rotation_checksum) - if len(rot_checksum_bytes) == 72: # Old format: 9 * 8 bytes (float64) - rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float64).astype(np.float32).reshape(3, 3) - else: # New format: 9 * 4 bytes (float32) - rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float32).reshape(3, 3) - - rot_real = np.array(obj.matrix_world.to_3x3(), dtype=np.float32) + rot_checksum_bytes: bytes = eval(oprops.rotation_checksum) + rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9) + rot_check = rot_check.reshape(3, 3) + rot_real = tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()) rot_dot = np.dot(rot_check, rot_real.T) angle_rad = np.arccos(np.clip((np.trace(rot_dot) - 1) / 2, -1, 1)) if angle_rad > 0.0017453292519943296: # 0.1 degrees diff --git a/src/bonsai/test/tool/test_geometry.py b/src/bonsai/test/tool/test_geometry.py index 34300f2405..424e1fd876 100644 --- a/src/bonsai/test/tool/test_geometry.py +++ b/src/bonsai/test/tool/test_geometry.py @@ -323,8 +323,8 @@ class TestRecordObjectPosition(NewFile): obj = bpy.data.objects.new("Object", None) props = tool.Blender.get_object_bim_props(obj) subject.record_object_position(obj) - assert props.location_checksum == repr(np.array(obj.matrix_world.translation, dtype=np.float32).tobytes()) - assert props.rotation_checksum == repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes()) + assert props.location_checksum == repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes()) + assert props.rotation_checksum == repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes()) class TestRemoveConnection(NewFile): From 50d00eba2031a2b571fd391d49394bd570868fa5 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 4 Feb 2026 18:00:07 +0500 Subject: [PATCH 50/60] black . --- src/bonsai/bonsai/bim/module/model/profile.py | 3 +-- src/bonsai/bonsai/bim/module/project/prop.py | 2 +- src/bonsai/bonsai/bim/module/root/data.py | 4 ++-- src/bonsai/bonsai/bim/module/root/operator.py | 4 +++- src/bonsai/bonsai/bim/module/system/operator.py | 17 ++++++++--------- src/bonsai/bonsai/bim/module/system/ui.py | 4 +++- 6 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index 0a11fa191b..572898c484 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -1136,8 +1136,7 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"]) if connect_IfcFlowSegments: bpy.ops.bim.mep_connect_elements( - obj1_name=profile1["obj"].name, - obj2_name=profile2["obj"].name + obj1_name=profile1["obj"].name, obj2_name=profile2["obj"].name ) def modal(self, context, event): diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 1c62c98d61..08859af1f9 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -370,7 +370,7 @@ class BIMProjectProperties(PropertyGroup): load_indexed_maps: BoolProperty( name="Load Indexed Maps", description="Load indexed maps (UV and color maps)", - default=False, # Very slow and hackishly implemented + default=False, # Very slow and hackishly implemented ) links: CollectionProperty(name="Links", type=Link) active_link_index: IntProperty(name="Active Link Index") diff --git a/src/bonsai/bonsai/bim/module/root/data.py b/src/bonsai/bonsai/bim/module/root/data.py index 50f3248d8f..7adf7b5fb9 100644 --- a/src/bonsai/bonsai/bim/module/root/data.py +++ b/src/bonsai/bonsai/bim/module/root/data.py @@ -172,7 +172,7 @@ class IfcClassData: ), ) ) - if (ifc_class and "IfcCableCarrierSegment" in ifc_class): + if ifc_class and "IfcCableCarrierSegment" in ifc_class: templates.extend( ( ( @@ -182,7 +182,7 @@ class IfcClassData: ), ) ) - + if ifc_class.endswith("Type") or ifc_class.endswith("Style"): templates.extend( [ diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 983429d311..ac766172bd 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -703,7 +703,9 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): default_thickness = 0.005 default_inner_fillet_radius = 0.005 default_outer_fillet_radius = 0.005 - profile_name = f"{props.ifc_class}-{default_x_dim*1000}x{default_y_dim*1000}x{default_thickness*1000}" + profile_name = ( + f"{props.ifc_class}-{default_x_dim*1000}x{default_y_dim*1000}x{default_thickness*1000}" + ) profile = tool.Ifc.get().create_entity( "IfcRectangleHollowProfileDef", ProfileName=profile_name, diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index 18e8d91d88..089bd9f24b 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -488,12 +488,11 @@ class EstablishPathDirection(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): connected_port = tool.Ifc.get().by_id(self.port_id) - - + if not connected_port or not connected_port.is_a("IfcDistributionPort"): self.report({"ERROR"}, "Invalid port specified.") return {"CANCELLED"} - + direction_map = { "SOURCE": "SINK", "SINK": "SOURCE", @@ -503,28 +502,28 @@ class EstablishPathDirection(bpy.types.Operator, tool.Ifc.Operator): next_element = tool.System.get_port_relating_element(connected_port) ports = tool.System.get_ports(next_element) segments_processed = 0 - while (len(ports) == 2): + while len(ports) == 2: if ports[0].id() == connected_port.id(): other_port = ports[1] else: other_port = ports[0] - + new_direction = direction_map.get(connected_port.FlowDirection, "NOTDEFINED") other_port.FlowDirection = new_direction segments_processed += 1 - + connected_port = tool.System.get_connected_port(other_port) if not connected_port: break connected_port.FlowDirection = direction_map.get(other_port.FlowDirection, "NOTDEFINED") next_element = tool.System.get_port_relating_element(connected_port) - + if not next_element.is_a("IfcFlowSegment"): print(f"DEBUG: next_element is not IfcFlowSegment, stopping") break - + ports = tool.System.get_ports(next_element) - + self.report({"INFO"}, f"Established path direction through {segments_processed} flow segment(s).") return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/system/ui.py b/src/bonsai/bonsai/bim/module/system/ui.py index a3d73c10f6..e8f8b7daeb 100644 --- a/src/bonsai/bonsai/bim/module/system/ui.py +++ b/src/bonsai/bonsai/bim/module/system/ui.py @@ -333,7 +333,9 @@ class BIM_PT_port(Panel): else: cols[7].label(text="", icon="BLANK1") cols[8].label(text="") - cols[9].operator("bim.establish_path_direction", text="", icon="CON_FOLLOWPATH").port_id = connected_port.id() + cols[9].operator("bim.establish_path_direction", text="", icon="CON_FOLLOWPATH").port_id = ( + connected_port.id() + ) else: cols[4].label(text="", icon="BLANK1") cols[5].label(text="", icon="BLANK1") From f10d4602fca23e05dcc62433c70e06ff9e92d609 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 4 Feb 2026 19:21:02 +0500 Subject: [PATCH 51/60] typing --- .../bonsai/bim/module/search/operator.py | 72 ++++++++++--------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index 47e87a3caa..f57a313502 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -45,8 +45,12 @@ import bonsai.tool as tool from bonsai.bim.ifc import IfcStore from bonsai.bim.prop import StrProperty +if TYPE_CHECKING: + from bonsai.bim.prop import BIMFacet -def draw_text_editor_header(self, context): + +def draw_text_editor_header(self: bpy.types.TEXT_HT_header, context: bpy.types.Context) -> None: + assert isinstance(context.space_data, bpy.types.SpaceTextEditor) if context.space_data.text and context.space_data.text.name.startswith("FilterQuery_"): layout = self.layout layout.separator() @@ -160,6 +164,7 @@ class FilterValueSuggestions(Operator): def draw(self, context): layout = self.layout + assert layout filter_groups = tool.Search.get_filter_groups(self.module) ifc_filter = filter_groups[self.group_index].filters[self.filter_index] @@ -202,8 +207,8 @@ class FilterValueSuggestions(Operator): results_are_suggestions=True, ) - def get_suggestions(self, ifc_file, ifc_filter): - suggestions = set() + def get_suggestions(self, ifc_file: ifcopenshell.file, ifc_filter: "BIMFacet") -> set[str]: + suggestions: set[str] = set() if ifc_filter.type == "entity": suggestions = self.get_entity_suggestions(ifc_file) @@ -236,8 +241,8 @@ class FilterValueSuggestions(Operator): return suggestions - def build_hierarchy_path(self, element): - path = [] + def build_hierarchy_path(self, element: ifcopenshell.entity_instance) -> list[str]: + path: list[str] = [] current = element while current: @@ -262,8 +267,8 @@ class FilterValueSuggestions(Operator): return path - def get_entity_suggestions(self, ifc_file): - all_classes = set() + def get_entity_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]: + all_classes: set[str] = set() schema = tool.Ifc.schema() for element_id in IfcStore.id_map.keys(): @@ -273,11 +278,13 @@ class FilterValueSuggestions(Operator): try: entity = schema.declaration_by_name(class_name).as_entity() + assert entity current = entity chain_names = [class_name] while current.supertype(): supertype = current.supertype() + assert supertype chain_names.insert(0, supertype.name()) current = supertype @@ -295,8 +302,8 @@ class FilterValueSuggestions(Operator): return all_classes - def get_type_suggestions(self, ifc_file): - suggestions = set() + def get_type_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]: + suggestions: set[str] = set() for element_type in ifc_file.by_type("IfcTypeObject"): if element_type.Name: hierarchy_path = self.build_hierarchy_path(element_type) @@ -306,15 +313,15 @@ class FilterValueSuggestions(Operator): suggestions.add(element_type.Name) return suggestions - def get_material_suggestions(self, ifc_file): + def get_material_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]: suggestions = set() for material in ifc_file.by_type("IfcMaterial"): if material.Name: suggestions.add(material.Name) return suggestions - def get_location_suggestions(self, ifc_file): - suggestions = set() + def get_location_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]: + suggestions: set[str] = set() for spatial in ifc_file.by_type("IfcSpatialStructureElement"): if spatial.Name: hierarchy_path = self.build_hierarchy_path(spatial) @@ -324,8 +331,8 @@ class FilterValueSuggestions(Operator): suggestions.add(spatial.Name) return suggestions - def get_group_suggestions(self, ifc_file): - suggestions = set() + def get_group_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]: + suggestions: set[str] = set() for group in ifc_file.by_type("IfcGroup"): if group.Name: hierarchy_path = self.build_hierarchy_path(group) @@ -335,15 +342,15 @@ class FilterValueSuggestions(Operator): suggestions.add(group.Name) return suggestions - def get_classification_suggestions(self, ifc_file): - suggestions = set() + def get_classification_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]: + suggestions: set[str] = set() for ref in ifc_file.by_type("IfcClassificationReference"): if ref.Identification: suggestions.add(ref.Identification) return suggestions - def get_parent_suggestions(self, ifc_file): - suggestions = set() + def get_parent_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]: + suggestions: set[str] = set() for element_id in IfcStore.id_map.keys(): try: element = ifc_file.by_id(element_id) @@ -371,9 +378,9 @@ class FilterValueSuggestions(Operator): return suggestions - def get_instance_suggestions(self, ifc_file): - suggestions = set() - element_data = [] + def get_instance_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]: + suggestions: set[str] = set() + element_data: list[tuple[str, str]] = [] for element_id in IfcStore.id_map.keys(): try: @@ -392,7 +399,7 @@ class FilterValueSuggestions(Operator): except: continue - display_counts = {} + display_counts: dict[str, int] = {} for display_str, global_id in element_data: display_counts[display_str] = display_counts.get(display_str, 0) + 1 @@ -404,8 +411,8 @@ class FilterValueSuggestions(Operator): return suggestions - def get_property_sets(self, ifc_file): - psets = set() + def get_property_sets(self, ifc_file: ifcopenshell.file) -> set[str]: + psets: set[str] = set() for element_id in IfcStore.id_map.keys(): try: element = ifc_file.by_id(element_id) @@ -418,8 +425,8 @@ class FilterValueSuggestions(Operator): continue return psets - def get_property_names(self, ifc_file, pset_name): - property_names = set() + def get_property_names(self, ifc_file: ifcopenshell.file, pset_name: str) -> set[str]: + property_names: set[str] = set() for element_id in IfcStore.id_map.keys(): try: element = ifc_file.by_id(element_id) @@ -435,8 +442,8 @@ class FilterValueSuggestions(Operator): continue return property_names - def get_property_values(self, ifc_file, pset_name, property_name): - property_values = set() + def get_property_values(self, ifc_file: ifcopenshell.file, pset_name: str, property_name: str) -> set[str]: + property_values: set[str] = set() for element_id in IfcStore.id_map.keys(): try: element = ifc_file.by_id(element_id) @@ -462,8 +469,8 @@ class FilterValueSuggestions(Operator): continue return property_values - def get_attribute_names(self, ifc_file): - attribute_names = set() + def get_attribute_names(self, ifc_file: ifcopenshell.file) -> set[str]: + attribute_names: set[str] = set() schema = tool.Ifc.schema() ifc_classes = set() @@ -477,6 +484,7 @@ class FilterValueSuggestions(Operator): for ifc_class in ifc_classes: try: entity = schema.declaration_by_name(ifc_class).as_entity() + assert entity attributes = entity.all_attributes() for attr in attributes: attribute_names.add(attr.name()) @@ -485,8 +493,8 @@ class FilterValueSuggestions(Operator): return attribute_names - def get_attribute_values(self, ifc_file, attribute_name): - attribute_values = set() + def get_attribute_values(self, ifc_file: ifcopenshell.file, attribute_name: str) -> set[str]: + attribute_values: set[str] = set() for element_id in IfcStore.id_map.keys(): try: From 2f586d6b9e93c71c7128c3c8c5e66d6361da8ff0 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 4 Feb 2026 19:44:09 +0500 Subject: [PATCH 52/60] bonsai prefs - shorten some description strings for readibility --- src/bonsai/bonsai/bim/ui.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index f90d2165fb..d224761609 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -721,13 +721,20 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): chain_filter_with_set_operations: BoolProperty( 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", + 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, ) save_metadata_blend_file: BoolProperty( name="Save non ifc data to metadata blend File", - description="Save session data (window layout, settings) to a metadata blend file alongside the IFC file. This file is automatically loaded when opening the project.", + description=( + "Save session data (window layout, settings) to a metadata blend file alongside the IFC file. " + "This file is automatically loaded when opening the project." + ), default=False, ) metadata_blend_file_suffix: StringProperty( From 7411da2ae87a02bda70f5b1f6897d6ad8236d7aa Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 4 Feb 2026 19:46:35 +0500 Subject: [PATCH 53/60] bim.search - fix error when unselectable objects get in the way #7635 --- src/bonsai/bonsai/bim/module/search/operator.py | 9 ++++----- src/bonsai/bonsai/tool/blender.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index f57a313502..83f401a122 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -788,11 +788,10 @@ class Search(Operator): ) objs = [obj for e in results if isinstance(obj := tool.Ifc.get_object(e), bpy.types.Object)] - for obj in objs: - tool.Blender.set_object_selection(obj) - if objs: - tool.Blender.set_active_object(objs[0]) - self.report({"INFO"}, f"{len(results)} Results.") + active_object = next(iter(objs), None) + selection = tool.Blender.validate_object_selection(context, active_object, objs) + tool.Blender.set_objects_selection(*selection, clear_previous_selection=False) + self.report({"INFO"}, f"{len(results)} Results, {len(selection.selected_objects)} Objects Selected") return {"FINISHED"} diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index b78772fbc5..80694917a5 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -36,6 +36,7 @@ from typing import ( TYPE_CHECKING, Any, Literal, + NamedTuple, Optional, TypeVar, Union, @@ -718,13 +719,18 @@ class Blender(bonsai.core.tool.Blender): if active_object: active_object.select_set(True) + class ObjectsSelectionArgs(NamedTuple): + context: bpy.types.Context + active_object: bpy.types.Object | None + selected_objects: list[bpy.types.Object] + @classmethod def validate_object_selection( cls, context: bpy.types.Context, active_object: Union[bpy.types.Object, None] = None, selected_objects: Sequence[bpy.types.Object] = (), - ) -> tuple[bpy.types.Context, Union[bpy.types.Object, None], list[bpy.types.Object]]: + ) -> ObjectsSelectionArgs: """Validate object selection and return only valid objects. Can be used before ``set_objects_selection`` to avoid errors @@ -739,7 +745,7 @@ class Blender(bonsai.core.tool.Blender): if active_object and not cls.is_valid_data_block(active_object): active_object = None - return context, active_object, new_selected_objects + return cls.ObjectsSelectionArgs(context, active_object, new_selected_objects) @classmethod def clear_objects_selection(cls) -> None: From 7e1843b96224cd195ac7bcc79ddaa4efbd928083 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 4 Feb 2026 19:49:31 +0500 Subject: [PATCH 54/60] dev_environment - support Blender 5.1 with Python 3.13 --- src/bonsai/scripts/dev_environment.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bonsai/scripts/dev_environment.py b/src/bonsai/scripts/dev_environment.py index d16edad96d..e9a8d2a44f 100644 --- a/src/bonsai/scripts/dev_environment.py +++ b/src/bonsai/scripts/dev_environment.py @@ -78,7 +78,8 @@ BONSAI_PATH = find_bonsai_path() # --------------------------- # Never changed by user. -PACKAGE_PATH = BLENDER_PATH / r"extensions/.local/lib/python3.11/site-packages" +PYTHON_VERSION = "3.13" if BLENDER_VERSION == "5.1" else "3.11" +PACKAGE_PATH = BLENDER_PATH / rf"extensions/.local/lib/python{PYTHON_VERSION}/site-packages" def main() -> None: From 79ef6eb55c337a4804d1480c992eb7148603a14f Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 4 Feb 2026 19:55:35 +0500 Subject: [PATCH 55/60] validate_object_selection - fix missing check for active object not present in a view layer --- src/bonsai/bonsai/tool/blender.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 80694917a5..e499f35106 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -740,9 +740,12 @@ class Blender(bonsai.core.tool.Blender): assert context.view_layer view_layer_objects = set(context.view_layer.objects) - new_selected_objects = [o for o in selected_objects if cls.is_valid_data_block(o) and o in view_layer_objects] + def is_selectable(obj: bpy.types.Object) -> bool: + return cls.is_valid_data_block(obj) and obj in view_layer_objects - if active_object and not cls.is_valid_data_block(active_object): + new_selected_objects = [o for o in selected_objects if is_selectable(o)] + + if active_object and not is_selectable(active_object): active_object = None return cls.ObjectsSelectionArgs(context, active_object, new_selected_objects) From a54b21b838edd49636634e78ee44eb6430db86a7 Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Thu, 5 Feb 2026 04:37:58 -0800 Subject: [PATCH 56/60] Fixes documentation and adds test for expected type --- .../api/alignment/_create_offset_curve_representation.py | 7 ++++++- .../ifcopenshell/api/alignment/create_as_offset_curve.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_offset_curve_representation.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_offset_curve_representation.py index 6850013c96..bb3fa6cbe8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_offset_curve_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_offset_curve_representation.py @@ -28,7 +28,7 @@ def _create_offset_curve_representation( file: ifcopenshell.file, alignment: entity_instance, offsets: Sequence[entity_instance] ) -> None: """ - Create geometric representation for the alignment based on an IfcPolyline + Create geometric representation for the alignment based on an IfcOffsetByDistances curve :param alignment: The alignment for which the representation is being created :return: None @@ -36,6 +36,11 @@ def _create_offset_curve_representation( expected_type = "IfcAlignment" if not alignment.is_a(expected_type): raise TypeError(f"Expected {expected_type} but got {alignment.is_a()}") + + expected_type = "IfcPointByDistaceExpression" + for offset in offsets: + if not offset.is_a(expected_type): + raise TypeError(f"Expected {expected_type} but got {offset.is_a()}") axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_offset_curve.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_offset_curve.py index b24fb16b29..6ac56af362 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_offset_curve.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_offset_curve.py @@ -39,7 +39,7 @@ def create_as_offset_curve( :param file: :param name: name assigned to IfcAlignment.Name - :param offsets: offsets from the basis curve that defines the offset curve, expected to be IfcOffsetCurveByDistances. + :param offsets: offsets from the basis curve that defines the offset curve, expected to be IfcPointByDistanceExpression. :param start_station: station value at the start of the alignment :return: Returns an IfcAlignment """ From 2692341d3ad7c921412b3c8ab7f4f05801207e1f Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:10:33 -0800 Subject: [PATCH 57/60] Fixes typo --- .../api/alignment/_create_offset_curve_representation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_offset_curve_representation.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_offset_curve_representation.py index bb3fa6cbe8..a5675a4dc2 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_offset_curve_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_offset_curve_representation.py @@ -37,7 +37,7 @@ def _create_offset_curve_representation( if not alignment.is_a(expected_type): raise TypeError(f"Expected {expected_type} but got {alignment.is_a()}") - expected_type = "IfcPointByDistaceExpression" + expected_type = "IfcPointByDistanceExpression" for offset in offsets: if not offset.is_a(expected_type): raise TypeError(f"Expected {expected_type} but got {offset.is_a()}") From 5e9f97a0c7a7776f744d8ef899042635c2dd96e1 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 6 Feb 2026 16:12:14 +1100 Subject: [PATCH 58/60] Fix #5220. See #7629. See #7505. Reimplement text bulk copying using established copy attribute paradigm. Previously, copy attribution was coupled with text editing. This meant that you couldn't just do something like change the font or alignment without also affecting literals. Now like most apps you can just select bunch of text and change font size etc, using the same UI look and feel that copying attribute has when editing attributes. This refactor also removes the need for explicit props tracking each possible attribute to copy, and the settings collection group. Bulk applying is now done in core with no calls to UI. --- .../bonsai/bim/module/drawing/__init__.py | 2 +- src/bonsai/bonsai/bim/module/drawing/data.py | 4 - .../bonsai/bim/module/drawing/operator.py | 28 ++-- src/bonsai/bonsai/bim/module/drawing/prop.py | 82 ++++------- .../bonsai/bim/module/drawing/svgwriter.py | 11 +- src/bonsai/bonsai/bim/module/drawing/ui.py | 137 ++++++++---------- src/bonsai/bonsai/core/drawing.py | 41 +++++- src/bonsai/bonsai/core/tool.py | 7 +- src/bonsai/bonsai/tool/drawing.py | 73 ++++++---- 9 files changed, 192 insertions(+), 193 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 67a9ed0853..9f172ce2bb 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -45,6 +45,7 @@ classes = ( operator.CleanWireframes, operator.ContractSheet, operator.ConvertSVGToDXF, + operator.CopyTextToSelection, operator.CreateDrawing, operator.CreateSheets, operator.DisableAddAnnotationType, @@ -115,7 +116,6 @@ classes = ( prop.BIMCameraProperties, prop.ElementValueRow, prop.LiteralProps, - prop.LiteralApplySettings, prop.BIMTextProperties, prop.BIMAssignedProductProperties, prop.BIMAnnotationProperties, diff --git a/src/bonsai/bonsai/bim/module/drawing/data.py b/src/bonsai/bonsai/bim/module/drawing/data.py index ed457a7bbf..a4396d84c5 100644 --- a/src/bonsai/bonsai/bim/module/drawing/data.py +++ b/src/bonsai/bonsai/bim/module/drawing/data.py @@ -355,8 +355,6 @@ class DecoratorData: font_size = FONT_SIZES[font_size_type] symbol = tool.Drawing.get_annotation_symbol(element) newline_at = pset_data.get("Newline_At", 0) - reverse_list = pset_data.get("Reverse_List", False) - list_separator = pset_data.get("List_Separator") or ", " # other attributes literals = tool.Drawing.get_text_literal(obj, return_list=True) @@ -384,8 +382,6 @@ class DecoratorData: "FontSize": font_size, "Symbol": symbol, "Newline_At": newline_at, - "Reverse_List": reverse_list, - "List_Separator": list_separator, } @classmethod diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 4c52993aa0..d02e4c6c76 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3181,9 +3181,20 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.edit_text" bl_label = "Edit Text" bl_description = "Save changes to the text annotation and\ndisable the text editing options" - bl_options = {"REGISTER", "UNDO"} + def _execute(self, context): + core.edit_text(tool.Drawing, obj=tool.Blender.get_active_object()) + tool.Blender.update_viewport() + + +class CopyTextToSelection(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.copy_text_to_selection" + bl_label = "Copy Text To Selection" + bl_description = "Copy text formatting or literals to selected objects" + bl_options = {"REGISTER", "UNDO"} + attribute: bpy.props.StringProperty() + def _execute(self, context): apply_objs = [ obj @@ -3191,7 +3202,12 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator): if (element := tool.Ifc.get_entity(obj)) and tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]) ] - core.edit_text(tool.Drawing, attribute_obj=tool.Blender.get_active_object(), apply_objs=apply_objs) + core.copy_text_to_selection( + tool.Drawing, + attribute=self.attribute, + attribute_obj=tool.Blender.get_active_object(), + apply_objs=apply_objs, + ) tool.Blender.update_viewport() @@ -3207,8 +3223,6 @@ class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator): props = tool.Drawing.get_text_props(obj) core.enable_editing_text(tool.Drawing, obj=obj) - props.ensure_literal_apply_settings(len(props.literals)) - text_element = tool.Ifc.get_entity(obj) assigned_product_entity = tool.Drawing.get_assigned_product(text_element) if text_element else None assigned_product_obj = tool.Ifc.get_object(assigned_product_entity) if assigned_product_entity else None @@ -3297,9 +3311,6 @@ class AddTextLiteral(bpy.types.Operator): box_alignment_mask = [False] * 9 box_alignment_mask[6] = True # bottom_left box_alignment literal_props.box_alignment = box_alignment_mask - - props.ensure_literal_apply_settings(len(props.literals)) - return {"FINISHED"} @@ -3318,9 +3329,6 @@ class RemoveTextLiteral(bpy.types.Operator): props = tool.Drawing.get_text_props(obj) props.literals.remove(self.literal_prop_id) tool.Blender.update_viewport() - - props.ensure_literal_apply_settings(len(props.literals)) - return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index 3b7040bc02..b323b8d41e 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -872,22 +872,20 @@ class LiteralProps(PropertyGroup): category_for_adding: str -class LiteralApplySettings(PropertyGroup): - literal_index: IntProperty(name="Literal Index") - apply_text_to_all: BoolProperty(name="Apply Text to All", default=False) - apply_path_to_all: BoolProperty(name="Apply Path to All", default=False) - apply_box_alignment_to_all: BoolProperty(name="Apply Box Alignment to All", default=False) - - if TYPE_CHECKING: - literal_index: int - apply_text_to_all: bool - apply_path_to_all: bool - apply_box_alignment_to_all: bool - - class BIMTextProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing", default=False) literals: CollectionProperty(name="Literals", type=LiteralProps) + newline_at: IntProperty(name="Newline At") + symbol: EnumProperty( # pyright: ignore[reportRedeclaration] + name="Symbol", + description="Symbol from symbols.svg to use for this text.", + items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS], + default="NO SYMBOL", + ) + custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration] + name="Custom Symbol", + description="Non-default symbol to use for this text.", + ) font_size: EnumProperty( items=[ ("1.8", "1.8 - Small", ""), @@ -899,52 +897,34 @@ class BIMTextProperties(PropertyGroup): default="2.5", name="Font Size", ) - newline_at: IntProperty(name="Newline At") - reverse_list: BoolProperty(name="Reverse List", description="Reverses the order of any list.", default=False) - list_separator: StringProperty( # pyright: ignore[reportRedeclaration] - name="List Separator", - description="Text used to separate lists. Uses a comma (, ) if empty.", + align_horizontal: EnumProperty( + items=[ + ("left", "Left", "", "ALIGN_LEFT", 0), + ("middle", "Middle", "", "ALIGN_CENTER", 1), + ("right", "Right", "", "ALIGN_RIGHT", 2), + ], + default="left", + name="Horizontal Alignment", ) - symbol: EnumProperty( # pyright: ignore[reportRedeclaration] - name="Symbol", - description="Symbol from symbols.svg to use for this text.", - items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS], - default="NO SYMBOL", + align_vertical: EnumProperty( + items=[ + ("top", "Top", "", "ALIGN_TOP", 0), + ("middle", "Middle", "", "ALIGN_MIDDLE", 1), + ("bottom", "Bottom", "", "ALIGN_BOTTOM", 2), + ], + default="middle", + name="Vertical Alignment", ) - custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration] - name="Custom Symbol", - description="Non-default symbol to use for this text.", - ) - - apply_font_size_to_all: BoolProperty( - name="Apply Font Size to All", description="Apply font size changes to all selected text objects", default=False - ) - apply_newline_to_all: BoolProperty( - name="Apply Newline to All", description="Apply newline changes to all selected text objects", default=False - ) - - literal_apply_settings: CollectionProperty(name="Literal Apply Settings", type=LiteralApplySettings) - - def ensure_literal_apply_settings(self, literal_count: int): - """Ensure we have apply settings for all literals""" - while len(self.literal_apply_settings) > literal_count: - self.literal_apply_settings.remove(len(self.literal_apply_settings) - 1) - - while len(self.literal_apply_settings) < literal_count: - setting = self.literal_apply_settings.add() - setting.literal_index = len(self.literal_apply_settings) - 1 if TYPE_CHECKING: is_editing: bool literals: bpy.types.bpy_prop_collection_idprop[LiteralProps] - font_size: str newline_at: int - reverse_list: bool - list_separator: str symbol: Union[str, Literal["NO SYMBOL", "CUSTOM SYMBOL"]] custom_symbol: str - apply_font_size_to_all: bool - apply_newline_to_all: bool + font_size: str + align_horizontal: str + align_vertical: str def get_symbol(self) -> Union[str, None]: if self.symbol == "NO SYMBOL": @@ -979,8 +959,6 @@ class BIMTextProperties(PropertyGroup): "FontSize": float(self.font_size), "Newline_At": int(self.newline_at), "Symbol": self.get_symbol(), - "Reverse_List": self.reverse_list, - "List_Separator": self.list_separator or ", ", } return text_data diff --git a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py index dac8e54a61..ef64fdefc6 100644 --- a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py +++ b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py @@ -989,11 +989,6 @@ class SvgWriter: symbol = tool.Drawing.get_annotation_symbol(element) newline_at = tool.Drawing.get_newline_at(element) - # Get reverse_list and list_separator from EPset_Annotation - pset_data = ifcopenshell.util.element.get_pset(element, "EPset_Annotation") or {} - reverse_list = pset_data.get("Reverse_List", False) - list_separator = pset_data.get("List_Separator") or ", " - template_text_fields = [] if symbol: symbol_transform = self.get_symbol_transform(text_position_svg_str, angle, text_obj) @@ -1010,7 +1005,7 @@ class SvgWriter: # NOTE: zip makes sure that we iterate over the shortest list for field, text_literal in zip(template_text_fields, text_literals): field.text = tool.Drawing.replace_text_literal_variables( - text_literal.Literal, product or element, reverse_list, list_separator + text_literal.Literal, product or element ) field.attrib["class"] = classes_str @@ -1030,9 +1025,7 @@ class SvgWriter: line_number = 0 for text_literal in text_literals: - text = tool.Drawing.replace_text_literal_variables( - text_literal.Literal, product or element, reverse_list, list_separator - ) + text = tool.Drawing.replace_text_literal_variables(text_literal.Literal, product or element) text_segments = parse_markdown_it(text) diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 45ae50beb4..556459b8b6 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -644,58 +644,56 @@ class BIM_PT_text(Panel): row.operator("bim.add_text_literal", icon="ADD", text="Add Literal") else: row.operator("bim.edit_text", icon="CHECKMARK") - row.operator("bim.add_text_literal", icon="ADD", text="") row.operator("bim.disable_editing_text", icon="CANCEL", text="") row = self.layout.row(align=True) row.prop(props, "font_size") - row.prop(props, "apply_font_size_to_all", text="", icon="COPYDOWN") + row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "FONT_SIZE" + row = self.layout.row() + row.label(text="Alignment") + row.prop(props, "align_horizontal", text="", expand=True) + row.prop(props, "align_vertical", text="", expand=True) + row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "ALIGNMENT" row = self.layout.row(align=True) row.prop(props, "newline_at") - row = self.layout.row(align=True) - row.prop(props, "reverse_list") - row = self.layout.row(align=True) - row.prop(props, "list_separator") + row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "WRAP_LENGTH" row = self.layout.row(align=True) row.prop(props, "symbol") if props.symbol == "CUSTOM SYMBOL": row = self.layout.row(align=True) row.prop(props, "custom_symbol", text="") - row.prop(props, "apply_newline_to_all", text="", icon="COPYDOWN") select_op = row.operator("bim.select_similar_text_literal_value", text="", icon="RESTRICT_SELECT_OFF") select_op.literal_value = str(props.newline_at) select_op.attribute_type = "newline" + row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "SYMBOL" + + row = self.layout.row(align=True) + row.label(text="Literals:") + row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "LITERALS" + row.operator("bim.add_text_literal", icon="ADD", text="") for i, literal_props in enumerate(props.literals): box = self.layout.box() - - row = box.row(align=True) - row.label(text=f"Literal[{i}]:") - if i > 0: - row.operator("bim.order_text_literal_up", icon="TRIA_UP", text="").literal_prop_id = i - if i < len(props.literals) - 1: - row.operator("bim.order_text_literal_down", icon="TRIA_DOWN", text="").literal_prop_id = i - row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i - - if len(literal_props.attributes) > 0 and i < len(props.literal_apply_settings): + if len(literal_props.attributes): row = box.row(align=True) bonsai.bim.helper.draw_attribute(literal_props.attributes[0], row, enable_search=True) + if i > 0: + row.operator("bim.order_text_literal_up", icon="TRIA_UP", text="").literal_prop_id = i + if i < len(props.literals) - 1: + row.operator("bim.order_text_literal_down", icon="TRIA_DOWN", text="").literal_prop_id = i + row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i expand_icon = "DOWNARROW_HLT" if getattr(literal_props, "show_element_values", False) else "RIGHTARROW" op = row.operator("bim.toggle_element_values_panel", icon=expand_icon, text="") op.literal_prop_id = i - row.prop(props.literal_apply_settings[i], "apply_text_to_all", text="", icon="COPYDOWN") - element = tool.Ifc.get_entity(obj) assigned_element = tool.Drawing.get_assigned_product(element) or element resolved_value = tool.Drawing.replace_text_literal_variables( literal_props.attributes[0].string_value, assigned_element, - props.reverse_list, - props.list_separator, ) row = box.row(align=True) row.label(text="CurrentValue:") @@ -779,8 +777,6 @@ class BIM_PT_text(Panel): else: row.prop(attr, "string_value", text="Path") select_value = attr.string_value - if i < len(props.literal_apply_settings): - row.prop(props.literal_apply_settings[i], "apply_path_to_all", text="", icon="COPYDOWN") other_attributes = [a for a in literal_props.attributes[2:] if a.name != "BoxAlignment"] if other_attributes: @@ -800,10 +796,6 @@ class BIM_PT_text(Panel): col = row.column(align=True) alignment_label_row = col.row(align=True) alignment_label_row.label(text=" Text box alignment:") - if i < len(props.literal_apply_settings): - alignment_label_row.prop( - props.literal_apply_settings[i], "apply_box_alignment_to_all", text="", icon="COPYDOWN" - ) box_alignment_value = ( literal_props.attributes[ @@ -824,61 +816,52 @@ class BIM_PT_text(Panel): props = tool.Drawing.get_text_props(obj) if props.is_editing: - self.draw_text_editing_ui(context) - else: - text_data = DecoratorData.get_text_data(obj) + return self.draw_text_editing_ui(context) + text_data = DecoratorData.get_text_data(obj) - row = self.layout.row() - row.operator("bim.enable_editing_text", icon="GREASEPENCIL") + row = self.layout.row() + row.operator("bim.enable_editing_text", icon="GREASEPENCIL") - row = self.layout.row(align=True) - row.label(text="FontSize") - click_op = row.operator( - "bim.select_similar_text_literal_value", text=str(text_data["FontSize"]), emboss=False - ) - click_op.literal_value = str(text_data["FontSize"]) - click_op.attribute_type = "font_size" - click_op.display_text = str(text_data["FontSize"]) + row = self.layout.row(align=True) + row.label(text="FontSize") + click_op = row.operator("bim.select_similar_text_literal_value", text=str(text_data["FontSize"]), emboss=False) + click_op.literal_value = str(text_data["FontSize"]) + click_op.attribute_type = "font_size" + click_op.display_text = str(text_data["FontSize"]) - row = self.layout.row(align=True) - row.label(text="Newline_At") - click_op = row.operator( - "bim.select_similar_text_literal_value", text=str(text_data["Newline_At"]), emboss=False - ) - click_op.literal_value = str(text_data["Newline_At"]) - click_op.attribute_type = "newline" - click_op.display_text = str(text_data["Newline_At"]) - row = self.layout.row(align=True) - row.label(text="Reverse_List") - row.label(text=str(text_data["Reverse_List"])) - row = self.layout.row(align=True) - row.label(text="List_Separator") - row.label(text=str(text_data["List_Separator"])) + row = self.layout.row(align=True) + row.label(text="Newline_At") + click_op = row.operator( + "bim.select_similar_text_literal_value", text=str(text_data["Newline_At"]), emboss=False + ) + click_op.literal_value = str(text_data["Newline_At"]) + click_op.attribute_type = "newline" + click_op.display_text = str(text_data["Newline_At"]) - for i, literal_data in enumerate(text_data["Literals"]): - box = self.layout.box() - box.label(text=f"Literal[{i}]:") + for i, literal_data in enumerate(text_data["Literals"]): + box = self.layout.box() + box.label(text=f"Literal[{i}]:") - # Combine both approaches: clickable attributes from PR #7292 and display from PR #7106 - for attribute in literal_data: - row = box.row(align=True) - row.label(text=attribute) - click_op = row.operator( - "bim.select_similar_text_literal_value", - text=str(literal_data[attribute]), - emboss=False, - ) - click_op.literal_value = str(literal_data[attribute]) - click_op.literal_index = i - if attribute == "Literal": - click_op.attribute_type = "literal" - elif attribute == "Path": - click_op.attribute_type = "path" - elif attribute == "BoxAlignment": - click_op.attribute_type = "box_alignment" - else: - click_op.attribute_type = "text" - click_op.display_text = str(literal_data[attribute]) + # Combine both approaches: clickable attributes from PR #7292 and display from PR #7106 + for attribute in literal_data: + row = box.row(align=True) + row.label(text=attribute) + click_op = row.operator( + "bim.select_similar_text_literal_value", + text=str(literal_data[attribute]), + emboss=False, + ) + click_op.literal_value = str(literal_data[attribute]) + click_op.literal_index = i + if attribute == "Literal": + click_op.attribute_type = "literal" + elif attribute == "Path": + click_op.attribute_type = "path" + elif attribute == "BoxAlignment": + click_op.attribute_type = "box_alignment" + else: + click_op.attribute_type = "text" + click_op.display_text = str(literal_data[attribute]) class BIM_UL_drawinglist(bpy.types.UIList): diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index eb2c47f1dc..42b0f77fbb 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -38,13 +38,42 @@ def disable_editing_text(drawing: type[tool.Drawing], obj: bpy.types.Object) -> drawing.disable_editing_text(obj) -def edit_text(drawing: type[tool.Drawing], attribute_obj: bpy.types.Object, apply_objs: list[bpy.types.Object]) -> None: - literal_attributes = drawing.export_text_literal_attributes(attribute_obj) +def edit_text(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None: + literal_attributes = drawing.export_text_literal_attributes(obj) + drawing.edit_text_font_size(obj, drawing.export_font_size(obj)) + drawing.edit_text_wrap_length(obj, drawing.export_wrap_length(obj)) + drawing.edit_text_symbol(obj, drawing.export_symbol(obj)) + drawing.edit_text_literals(obj, literal_attributes) + drawing.disable_editing_text(obj) + + +def copy_text_to_selection( + drawing: type[tool.Drawing], + attribute: Literal["FONT_SIZE", "ALIGNMENT", "WRAP_LENGTH", "SYMBOL", "LITERALS"], + attribute_obj: bpy.types.Object, + apply_objs: list[bpy.types.Object], +) -> None: + if attribute == "FONT_SIZE": + data = drawing.export_font_size(attribute_obj) + elif attribute == "ALIGNMENT": + data = drawing.export_alignment(attribute_obj) + elif attribute == "WRAP_LENGTH": + data = drawing.export_wrap_length(attribute_obj) + elif attribute == "SYMBOL": + data = drawing.export_symbol(attribute_obj) + elif attribute == "LITERALS": + data = drawing.export_text_literal_attributes(attribute_obj) for obj in apply_objs: - drawing.edit_text_literals(obj, literal_attributes) - # TODO: font size should be part of a separate set of formatting controls, not part of text editing - drawing.update_text_size_pset(obj) - drawing.update_text_annotation_properties(obj) + if attribute == "FONT_SIZE": + drawing.edit_text_font_size(obj, data) + elif attribute == "ALIGNMENT": + drawing.edit_text_alignment(obj, data) + elif attribute == "WRAP_LENGTH": + drawing.edit_text_wrap_length(obj, data) + elif attribute == "SYMBOL": + drawing.edit_text_symbol(obj, data) + elif attribute == "LITERALS": + drawing.edit_text_literals(obj, data) drawing.disable_editing_text(obj) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index a34de4d4fe..a0b6290b12 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -334,6 +334,11 @@ class Drawing: def disable_editing_sheets(cls): pass def disable_editing_text(cls, obj): pass def does_file_exist(cls, uri): pass + def edit_text_alignment(cls, obj, alignment): pass + def edit_text_font_size(cls, obj, size): pass + def edit_text_literals(cls, obj, literals): pass + def edit_text_symbol(cls, obj, symbol): pass + def edit_text_wrap_length(cls, obj, wrap_length): pass def enable_editing(cls, obj): pass def enable_editing_assigned_product(cls, obj): pass def enable_editing_drawings(cls): pass @@ -403,8 +408,6 @@ class Drawing: def show_decorations(cls): pass def sync_object_placement(cls, obj): pass def update_embedded_svg_location(cls, uri, old_location, new_location): pass - def update_text_annotation_properties(cls, obj): pass - def update_text_size_pset(cls, obj): pass @interface diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index a0a45b403a..3b98b92611 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -608,6 +608,25 @@ class Drawing(bonsai.core.tool.Drawing): literals.append(literal_data) return literals + @classmethod + def export_font_size(cls, obj: bpy.types.Object) -> str: + return float(cls.get_text_props(obj).font_size) + + @classmethod + def export_alignment(cls, obj: bpy.types.Object) -> str: + props = cls.get_text_props(obj) + if (alignment := props.align_vertical + "-" + props.align_horizontal) == "middle-middle": + return "center" + return alignment + + @classmethod + def export_wrap_length(cls, obj: bpy.types.Object) -> str: + return cls.get_text_props(obj).newline_at + + @classmethod + def export_symbol(cls, obj: bpy.types.Object) -> str: + return cls.get_text_props(obj).get_symbol() + @classmethod def create_annotation_context( cls, target_view: str, object_type: Optional[str] = None @@ -1181,8 +1200,6 @@ class Drawing(bonsai.core.tool.Drawing): props.font_size = str(text_data["FontSize"]) props.newline_at = text_data["Newline_At"] props.set_symbol(text_data["Symbol"]) - props.reverse_list = text_data["Reverse_List"] - props.list_separator = text_data["List_Separator"] @classmethod def import_assigned_product(cls, obj: bpy.types.Object) -> None: @@ -1279,7 +1296,7 @@ class Drawing(bonsai.core.tool.Drawing): props.should_draw_decorations = True @classmethod - def update_text_size_pset(cls, obj: bpy.types.Object) -> None: + def edit_text_font_size(cls, obj: bpy.types.Object, font_size: float) -> None: """updates pset `EPset_Annotation.Classes` value based on current font size from `obj.BIMTextProperties.font_size` """ @@ -1289,8 +1306,9 @@ class Drawing(bonsai.core.tool.Drawing): element = tool.Ifc.get_entity(obj) assert element # updating text font size in EPset_Annotation.Classes - font_size = float(props.font_size) + print("we got", font_size, repr(font_size)) font_size_str = next((key for key in FONT_SIZES if FONT_SIZES[key] == font_size), None) + print("so", font_size_str) classes = ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Classes") assert isinstance(classes, Union[str, None]) classes_split = classes.split() if classes else [] @@ -1310,34 +1328,32 @@ class Drawing(bonsai.core.tool.Drawing): pset = tool.Pset.get_element_pset(element, "EPset_Annotation") if not pset: pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation") - ifcopenshell.api.pset.edit_pset( - ifc_file, - pset=pset, - properties={"Classes": classes}, - ) + ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Classes": classes}) @classmethod - def update_text_annotation_properties(cls, obj: bpy.types.Object) -> None: - """Update all EPset_Annotation properties from the text props""" - props = cls.get_text_props(obj) + def edit_text_wrap_length(cls, obj: bpy.types.Object, wrap_length: int) -> None: element = tool.Ifc.get_entity(obj) - assert element - ifc_file = tool.Ifc.get() pset = tool.Pset.get_element_pset(element, "EPset_Annotation") if not pset: pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation") + ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Newline_At": wrap_length}) - ifcopenshell.api.pset.edit_pset( - ifc_file, - pset=pset, - properties={ - "Newline_At": int(props.newline_at), - "Symbol": props.get_symbol(), - "Reverse_List": props.reverse_list, - "List_Separator": props.list_separator or "", - }, - ) + @classmethod + def edit_text_symbol(cls, obj: bpy.types.Object, symbol: str) -> None: + element = tool.Ifc.get_entity(obj) + ifc_file = tool.Ifc.get() + pset = tool.Pset.get_element_pset(element, "EPset_Annotation") + if not pset: + pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation") + ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Symbol": symbol}) + + @classmethod + def edit_text_alignment(cls, obj: bpy.types.Object, alignment: str) -> None: + ifc_literals = cls.get_text_literal(obj, return_list=True) + for ifc_literal in ifc_literals or []: + if ifc_literal.is_a("IfcTextLiteralWithExtent"): + ifc_literal.BoxAlignment = alignment # TODO below this point is highly experimental prototype code with no tests @@ -2081,13 +2097,9 @@ class Drawing(bonsai.core.tool.Drawing): cls, text: str, product: Optional[ifcopenshell.entity_instance] = None, - reverse_list: bool = False, - list_separator: str = ", ", ) -> str: if not product: return text - if list_separator: - list_separator = list_separator.encode().decode("unicode_escape") for command in re.findall("``.*?``", text): original_command = command @@ -2102,10 +2114,7 @@ class Drawing(bonsai.core.tool.Drawing): for variable in re.findall("{{.*?}}", text): value = ifcopenshell.util.selector.get_element_value(product, variable[2:-2]) if isinstance(value, (list, tuple)): - if reverse_list: - value = list_separator.join(str(v) for v in reversed(value)) - else: - value = list_separator.join(str(v) for v in value) + value = ", ".join(str(v) for v in value) text = text.replace(variable, str(value)) return text From 02fab6eee219d4d04d2847379343af29cb4c3c3f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 6 Feb 2026 16:35:04 +1100 Subject: [PATCH 59/60] Fix #7634. Support formatting signed numbers. --- src/ifcopenshell-python/ifcopenshell/util/selector.py | 2 +- src/ifcopenshell-python/test/util/test_selector.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index abcba23a0f..17051d6917 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -144,7 +144,7 @@ format_grammar = lark.Lark( | mul_div "*" function -> multiply | mul_div "/" function -> divide - function: round | number | int | format_length | lower | upper | title | concat | substr | variable | ESCAPED_STRING | NUMBER | "(" expression ")" + function: round | number | int | format_length | lower | upper | title | concat | substr | variable | ESCAPED_STRING | SIGNED_NUMBER | "(" expression ")" variable: "{{" query_path "}}" query_path: /[^}]+/ diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index 0b90f3b278..959cacd02d 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -54,6 +54,7 @@ class TestFormat: def test_number_formatting(self): assert subject.format("round(123, 5)") == "125" assert subject.format('round("123", 5)') == "125" + assert subject.format('round(-123, 5)') == "-125" assert subject.format("int(123.123)") == "123" assert subject.format("int(123)") == "123" assert subject.format("number(123)") == "123" From 1d5108f934910917db887718fb45e4b33471b51d Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 6 Feb 2026 16:51:41 +0000 Subject: [PATCH 60/60] Fix linking for builds with -Wl,--as-needed --- src/ifcconvert/CMakeLists.txt | 1 + src/ifcwrap/CMakeLists.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/src/ifcconvert/CMakeLists.txt b/src/ifcconvert/CMakeLists.txt index a5e16180cb..af46bc7dfe 100644 --- a/src/ifcconvert/CMakeLists.txt +++ b/src/ifcconvert/CMakeLists.txt @@ -15,6 +15,7 @@ target_link_libraries( IfcGeom IfcParse Serializers + ${kernel_libraries} ${OpenCASCADE_LIBRARIES} ${Boost_LIBRARIES} ${HDF5_LIBRARIES} diff --git a/src/ifcwrap/CMakeLists.txt b/src/ifcwrap/CMakeLists.txt index 47420d9817..16e3a86565 100644 --- a/src/ifcwrap/CMakeLists.txt +++ b/src/ifcwrap/CMakeLists.txt @@ -140,6 +140,7 @@ else() target_link_libraries(ifcopenshell_wrapper PRIVATE ${IFCOPENSHELL_LIBRARIES} ${LIBSVGFILL}) endif() target_link_libraries(ifcopenshell_wrapper PRIVATE ${CGAL_LIBRARIES}) +target_link_libraries(ifcopenshell_wrapper PRIVATE IfcGeom ${kernel_libraries}) if((NOT WIN32) AND BUILD_SHARED_LIBS) SET_INSTALL_RPATHS(ifcopenshell_wrapper "${IFCDIRS};${OCC_LIBRARY_DIR}") endif()