From 6f78d60c9fd73da9f74729248dba1cd9c98b5d13 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 28 Jan 2026 21:22:19 +1100 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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