From 021e6b9ef5b9ff60fd733570adde3d0254063f83 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 16 Mar 2026 15:15:12 +1100 Subject: [PATCH 01/76] Simplify get model types to just got all type products. Fixes failing test. --- src/bonsai/bonsai/tool/type.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/bonsai/bonsai/tool/type.py b/src/bonsai/bonsai/tool/type.py index 84a349f288..882c4b4618 100644 --- a/src/bonsai/bonsai/tool/type.py +++ b/src/bonsai/bonsai/tool/type.py @@ -75,16 +75,7 @@ class Type(bonsai.core.tool.Type): @classmethod def get_model_types(cls) -> list[ifcopenshell.entity_instance]: - ifc_file = tool.Ifc.get() - types = ifc_file.by_type("IfcElementType") - if tool.Ifc.get_schema() == "IFC2X3": - types += ifc_file.by_type("IfcWindowStyle") - types += ifc_file.by_type("IfcDoorStyle") - types += ifc_file.by_type("IfcSpatialStructureElementType") - else: - types += ifc_file.by_type("IfcSpatialElementType") - types += ifc_file.by_type("IfcTypeProduct", include_subtypes=False) - return types + return tool.Ifc.get().by_type("IfcTypeProduct") @classmethod def get_object_data(cls, obj: bpy.types.Object) -> Union[bpy.types.ID, None]: From b38316336cc2baed93dc8b8c1fe8e4352bf5765a Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 16 Mar 2026 17:52:02 +1100 Subject: [PATCH 02/76] Revert "Fix #6392: when duplicating a window/door/etc, the associated IfcOpenElement duplicates as well." This reverts commit a2a5780d59df97bfe31fdd41947b66253b65dcc3. --- src/bonsai/bonsai/bim/module/model/opening.py | 157 +++--------------- .../bonsai/bim/module/project/operator.py | 49 ------ src/bonsai/bonsai/tool/root.py | 42 ++--- 3 files changed, 29 insertions(+), 219 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index c20325c2e7..e680ceeb54 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -151,29 +151,11 @@ class FilledOpeningGenerator: existing_opening_occurrence, "Model", "Body", "MODEL_VIEW" ) assert representation - - # Check if mapped representation - PRESERVE the mapping structure - if ( - representation.RepresentationType == "MappedRepresentation" - and len(representation.Items) == 1 - and representation.Items[0].is_a("IfcMappedItem") - ): - # Store the existing RepresentationMap to reuse it - existing_mapping_source = representation.Items[0].MappingSource - reuse_mapped_representation = True - else: - representation = ifcopenshell.util.representation.resolve_representation(representation) - - if not reuse_mapped_representation: - # Check for library template before generating from filling - template_rep = self.get_opening_template_from_type(filling) - - if template_rep: - representation = template_rep - else: - representation = self.generate_opening_from_filling( - filling, filling_obj, opening_thickness_si=opening_thickness_si - ) + representation = ifcopenshell.util.representation.resolve_representation(representation) + else: + representation = self.generate_opening_from_filling( + filling, filling_obj, opening_thickness_si=opening_thickness_si + ) # Create mapped representation if reuse_mapped_representation: @@ -247,109 +229,38 @@ class FilledOpeningGenerator: voided_element = opening.VoidsElements[0].RelatingBuildingElement opening_rep = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW") - - # ALWAYS preserve the existing opening representation (Tessellation, SweptSolid, etc.) - preserved_representation = None - if opening_rep: - if ( - opening_rep.RepresentationType == "MappedRepresentation" - and len(opening_rep.Items) == 1 - and opening_rep.Items[0].is_a("IfcMappedItem") - ): - # For mapped representations, copy the underlying representation - preserved_representation = ifcopenshell.util.element.copy_deep( - tool.Ifc.get(), - opening_rep.Items[0].MappingSource.MappedRepresentation, - exclude=["IfcGeometricRepresentationContext"], - ) - else: - # For direct representations (non-mapped), copy them too - preserved_representation = ifcopenshell.util.element.copy_deep( - tool.Ifc.get(), opening_rep, exclude=["IfcGeometricRepresentationContext"] - ) - ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=opening, representation=opening_rep) ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=opening_rep) existing_opening_occurrence = self.get_existing_opening_occurrence_if_any(filling) - # Priority order for choosing representation: - # 1. Existing occurrence with MappedRepresentation (preserve mapping!) - # 2. Library template with Tessellation - # 3. Preserved representation from old opening (maintain user's work) - # 4. Generate from filling (last resort) - - representation_to_use = None - reuse_mapped_representation = False - existing_mapping_source = None - if existing_opening_occurrence: representation = ifcopenshell.util.representation.get_representation( existing_opening_occurrence, "Model", "Body", "MODEL_VIEW" ) - - if ( - representation - and representation.RepresentationType == "MappedRepresentation" - and len(representation.Items) == 1 - and representation.Items[0].is_a("IfcMappedItem") - ): - # PRESERVE the mapped structure - reuse the same RepresentationMap - existing_mapping_source = representation.Items[0].MappingSource - reuse_mapped_representation = True - else: - representation_to_use = ifcopenshell.util.representation.resolve_representation(representation) - - if not representation_to_use and not reuse_mapped_representation: - template_rep = self.get_opening_template_from_type(filling) - if template_rep and template_rep.RepresentationType == "Tessellation": - representation_to_use = template_rep - - if not representation_to_use and not reuse_mapped_representation and preserved_representation: - representation_to_use = preserved_representation - - if not representation_to_use and not reuse_mapped_representation: + representation = ifcopenshell.util.representation.resolve_representation(representation) + mapped_representation = ifcopenshell.api.geometry.map_representation( + tool.Ifc.get(), representation=representation + ) + ifcopenshell.api.geometry.assign_representation( + tool.Ifc.get(), product=opening, representation=mapped_representation + ) + else: opening_obj = tool.Ifc.get_object(opening) if opening_obj: tool.Ifc.unlink(element=opening) tool.Blender.remove_data_blocks([opening_obj], remove_unused_data=True) filling_obj = tool.Ifc.get_object(filling) - representation_to_use = self.generate_opening_from_filling(filling, filling_obj) - - # Create the mapped representation - if reuse_mapped_representation: - # Reuse existing RepresentationMap - don't create a new one! - context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") - new_mapped_item = tool.Ifc.get().create_entity( - "IfcMappedItem", - MappingSource=existing_mapping_source, - MappingTarget=tool.Ifc.get().create_entity( - "IfcCartesianTransformationOperator3D", - Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)), - Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 1.0, 0.0)), - LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)), - Scale=1.0, - Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)), - ), - ) - mapped_representation = tool.Ifc.get().create_entity( - "IfcShapeRepresentation", - ContextOfItems=context, - RepresentationIdentifier="Body", - RepresentationType="MappedRepresentation", - Items=[new_mapped_item], - ) - else: + representation = self.generate_opening_from_filling(filling, filling_obj) mapped_representation = ifcopenshell.api.geometry.map_representation( - tool.Ifc.get(), representation=representation_to_use + tool.Ifc.get(), representation=representation + ) + ifcopenshell.api.geometry.assign_representation( + tool.Ifc.get(), product=opening, representation=mapped_representation ) - ifcopenshell.api.geometry.assign_representation( - tool.Ifc.get(), product=opening, representation=mapped_representation - ) - - # update voided object representation... + # update voided object representation or all it's parts if it's an aggregate voided_elements = ifcopenshell.util.element.get_parts(voided_element) or [voided_element] for voided_element in voided_elements: voided_obj = tool.Ifc.get_object(voided_element) @@ -363,36 +274,6 @@ class FilledOpeningGenerator: representation=representation, ) - def get_opening_template_from_type( - self, filling: ifcopenshell.entity_instance - ) -> Union[ifcopenshell.entity_instance, None]: - """ - Check if the filling's type has a stored opening template from library import. - """ - element_type = ifcopenshell.util.element.get_type(filling) - - if not element_type: - return None - - desc = element_type.Description - - if not desc or "||BonsaiOpeningTemplate:" not in desc: - return None - - # Extract template ID - marker = desc.split("||BonsaiOpeningTemplate:")[-1] - template_id = int(marker.split("||")[0]) - - try: - template_rep = tool.Ifc.get().by_id(template_id) - # Make a copy so we don't reuse the same representation instance - copied = ifcopenshell.util.element.copy_deep( - tool.Ifc.get(), template_rep, exclude=["IfcGeometricRepresentationContext"] - ) - return copied - except: - return None - def generate_opening_from_filling( self, filling: ifcopenshell.entity_instance, diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 1cc5518eb4..9d0e2ff8d4 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -618,8 +618,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): if not element: return {"FINISHED"} if element.is_a("IfcTypeProduct"): - # Store opening template from library if it exists - self.store_opening_template_from_library(element, library_file) self.import_type_from_ifc(element, context) elif element.is_a("IfcProduct"): # NOTE: Non-types are not exposed in UI directly @@ -720,53 +718,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): if element.is_a("IfcSurfaceStyle") and not tool.Ifc.get_object_by_identifier(element.id()): ifc_importer.create_style(element) - def store_opening_template_from_library( - self, element: ifcopenshell.entity_instance, library_file: ifcopenshell.file - ) -> None: - """ - Find an opening representation in the library and copy it to the current file - as a template. Store the template ID on the type for later retrieval. - """ - try: - library_element = library_file.by_guid(element.GlobalId) - except: - return - - # Find occurrences with openings in the library - library_occurrences = ifcopenshell.util.element.get_types(library_element) - - for occurrence in library_occurrences: - if not getattr(occurrence, "FillsVoids", None): - continue - - library_opening = occurrence.FillsVoids[0].RelatingOpeningElement - library_opening_rep = ifcopenshell.util.representation.get_representation( - library_opening, "Model", "Body", "MODEL_VIEW" - ) - - if not library_opening_rep: - continue - - # Check if mapped representation - if ( - library_opening_rep.RepresentationType == "MappedRepresentation" - and len(library_opening_rep.Items) == 1 - and library_opening_rep.Items[0].is_a("IfcMappedItem") - ): - - mapped_rep = library_opening_rep.Items[0].MappingSource.MappedRepresentation - - # Store ALL representation types (Tessellation, SweptSolid, etc.) - template_rep = ifcopenshell.util.element.copy_deep( - self.file, mapped_rep, exclude=["IfcGeometricRepresentationContext"] - ) - - # Store reference in type's Description - current_desc = element.Description or "" - element.Description = f"{current_desc}||BonsaiOpeningTemplate:{template_rep.id()}" - return - break - class EditProjectLibrary(bpy.types.Operator): bl_idname = "bim.edit_project_library" diff --git a/src/bonsai/bonsai/tool/root.py b/src/bonsai/bonsai/tool/root.py index 02ddbe9745..8880a168fe 100644 --- a/src/bonsai/bonsai/tool/root.py +++ b/src/bonsai/bonsai/tool/root.py @@ -93,38 +93,16 @@ class Root(bonsai.core.tool.Root): elif dest.is_a("IfcTypeProduct"): if not source.RepresentationMaps: return copied_entities - - # Copy representation maps while preserving mapped representation structures - new_maps = [] - for i, rep_map in enumerate(source.RepresentationMaps): - source_rep = rep_map.MappedRepresentation - - # Copy the map itself - new_map = ifcopenshell.util.element.copy(tool.Ifc.get(), rep_map) - - # Handle the mapped representation - preserve mapping structure if present - if ( - source_rep.RepresentationType == "MappedRepresentation" - and len(source_rep.Items) == 1 - and source_rep.Items[0].is_a("IfcMappedItem") - ): - # This is a mapped representation - preserve the structure - new_rep = ifcopenshell.util.element.copy(tool.Ifc.get(), source_rep) - new_rep.Items = [ifcopenshell.util.element.copy(tool.Ifc.get(), item) for item in source_rep.Items] - new_map.MappedRepresentation = new_rep - else: - # Not a mapped representation - use copy_deep as before - new_map.MappedRepresentation = ifcopenshell.util.element.copy_deep( - tool.Ifc.get(), - source_rep, - exclude=["IfcGeometricRepresentationContext"], - exclude_callback=exclude_callback, - copied_entities=copied_entities, - ) - - new_maps.append(new_map) - - dest.RepresentationMaps = new_maps + dest.RepresentationMaps = [ + ifcopenshell.util.element.copy_deep( + tool.Ifc.get(), + m, + exclude=["IfcGeometricRepresentationContext"], + exclude_callback=exclude_callback, + copied_entities=copied_entities, + ) + for m in source.RepresentationMaps + ] return copied_entities @classmethod From cf153981ce78c8501b58a15078d5a81ed3dc66e5 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 16 Mar 2026 19:04:35 +1100 Subject: [PATCH 03/76] Feature tests for add/remove literal Co-Authored-By: Claude Opus 4.6 --- src/bonsai/bonsai/bim/module/drawing/ui.py | 1 - src/bonsai/test/bim/feature/drawing.feature | 45 +++++++++++++++++++ src/bonsai/test/bim/test_feature.py | 48 +++++++++++++++++++-- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 796c36b484..c1809995a0 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -816,7 +816,6 @@ class BIM_PT_text(Panel): 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: diff --git a/src/bonsai/test/bim/feature/drawing.feature b/src/bonsai/test/bim/feature/drawing.feature index cff150b3b4..0f0c5a3b17 100644 --- a/src/bonsai/test/bim/feature/drawing.feature +++ b/src/bonsai/test/bim/feature/drawing.feature @@ -385,6 +385,51 @@ Scenario: Edit text - change literal When I click "Edit Text" Then I see "Hello World" +Scenario: Add text literal + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I save IFC project + And I look at the "Drawings" panel + And I click "IMPORT" + And I click "ADD" + And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')" + And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list + And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list + And I press "bim.add_annotation" + And the object "IfcAnnotation/TEXT" is selected + And I look at the "BIM_PT_text" panel + And I click "Enable Editing Text" + And I click the "ADD" after the text "Literals:" + And I set the "2nd Literal" property to "New Literal" + When I click "Edit Text" + Then I see "New Literal" + +Scenario: Remove text literal + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I save IFC project + And I look at the "Drawings" panel + And I click "IMPORT" + And I click "ADD" + And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')" + And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list + And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list + And I press "bim.add_annotation" + And the object "IfcAnnotation/TEXT" is selected + And I look at the "BIM_PT_text" panel + And I click "Enable Editing Text" + And I set the "Literal" property to "Keep This" + And I click the "ADD" after the text "Literals:" + And I set the "2nd Literal" property to "Remove This" + And I click "Edit Text" + And I click "Enable Editing Text" + When I click the "2nd" "X" + And I click "Edit Text" + Then I see "Keep This" + And I don't see "Remove This" + Scenario: Add reference image Given an empty IFC project And I save IFC project diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index fe6fbcba15..3a1efa5d5b 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -610,8 +610,9 @@ def i_see_the_prop_property_is_value(prop, value): @then(parsers.parse('I set the "{prop}" property to "{value}"')) def i_set_the_prop_property_to_value(prop: str, value: str): """ - :param prop: Could be either property name, property text, property icon - or property index (e.g. "1st", "2nd", "5th"). + :param prop: Could be either property name, property text, property icon, + property index (e.g. "1st", "2nd", "5th"), or Nth named property + (e.g. "2nd Literal" for the 2nd property called "Literal"). :param value: For boolean propeties - 'TRUE' or 'FALSE'. """ @@ -619,12 +620,28 @@ def i_set_the_prop_property_to_value(prop: str, value: str): assert panel_spy panel_spy.refresh_spy() is_nth = False - if prop[0].isnumeric() and prop.endswith(("st", "nd", "th")): + is_nth_named = False + nth_target = 0 + prop_name = prop + if " " in prop and prop[0].isnumeric(): + parts = prop.split(" ", 1) + if parts[0].endswith(("st", "nd", "th")): + is_nth_named = True + nth_target = int(parts[0][:-2]) - 1 + prop_name = parts[1] + elif prop[0].isnumeric() and prop.endswith(("st", "nd", "th")): is_nth = True + named_count = 0 for nth, spied_prop in enumerate(panel_spy.spied_props): if is_nth and nth != int(prop[:-2]) - 1: continue - if not is_nth and prop not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]): + if is_nth_named: + if prop_name not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]): + continue + if named_count != nth_target: + named_count += 1 + continue + elif not is_nth and prop not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]): continue if spied_prop["prop_type"] == "BOOLEAN": if value == "TRUE": @@ -873,6 +890,29 @@ def i_click_button(button): _i_click_button_on_panel(button, panel_spy) +@given(parsers.parse('I click the "{nth}" "{button}"')) +@when(parsers.parse('I click the "{nth}" "{button}"')) +@then(parsers.parse('I click the "{nth}" "{button}"')) +def i_click_the_nth_button(nth, button): + """ + :param nth: Ordinal like "1st", "2nd", "3rd" to select the Nth matching button. + :param button: The text or icon of the button to click. + """ + assert panel_spy + panel_spy.refresh_spy() + target = int(nth[:-2]) - 1 + count = 0 + for spied_operator in panel_spy.spied_operators: + if spied_operator["text"] == button or spied_operator["icon"] == button: + if count == target: + spied_operator["operator"]("INVOKE_DEFAULT", **spied_operator["kwargs"]) + panel_spy.is_spy_dirty = True + return + count += 1 + debug = "\n".join([f"{i} {v}" for i, v in enumerate(panel_spy.spied_operators)]) + assert False, f"Could not find {nth} {button}:\n{debug}" + + @given(parsers.parse('I click the "{button}" after the text "{text}"')) @when(parsers.parse('I click the "{button}" after the text "{text}"')) @then(parsers.parse('I click the "{button}" after the text "{text}"')) From 62bb6cdf3337ddb080a4abb27095bc5ec80841ae Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 16 Mar 2026 19:31:25 +1100 Subject: [PATCH 04/76] Fix failing classification tests because they relied on spaces which are now hidden by default --- src/bonsai/test/tool/test_classification.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/bonsai/test/tool/test_classification.py b/src/bonsai/test/tool/test_classification.py index 7bfe2a44d7..a48df50db4 100644 --- a/src/bonsai/test/tool/test_classification.py +++ b/src/bonsai/test/tool/test_classification.py @@ -42,6 +42,7 @@ class TestAddClassificationReferenceFromBSDD(NewFile): bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) obj = bpy.data.objects["Cube"] bpy.ops.bim.assign_class(ifc_class="IfcSpace", predefined_type="SPACE", userdefined_type="") + tool.Blender.set_active_object(obj) element = tool.Ifc.get_entity(obj) assert element @@ -66,6 +67,7 @@ class TestAddClassificationReferenceFromBSDD(NewFile): bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) obj = bpy.data.objects["Cube"] bpy.ops.bim.assign_class(ifc_class="IfcSpace", predefined_type="SPACE", userdefined_type="") + tool.Blender.set_active_object(obj) element = tool.Ifc.get_entity(obj) assert element @@ -110,6 +112,7 @@ class TestAddClassificationReferenceFromBSDD(NewFile): bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) obj = bpy.data.objects["Cube"] bpy.ops.bim.assign_class(ifc_class="IfcSpace", predefined_type="SPACE", userdefined_type="") + tool.Blender.set_active_object(obj) element = tool.Ifc.get_entity(obj) assert element From a8d28fb4694a791e121ca89d5db9231ce9dcdd2d Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 15:09:09 +0500 Subject: [PATCH 05/76] Remove unused import, black . --- src/bonsai/bonsai/bim/module/drawing/prop.py | 1 - src/bonsai/bonsai/bim/module/project/workspace.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index 6019c480b9..57c182c02e 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -27,7 +27,6 @@ import ifcopenshell.api.pset import ifcopenshell.util.element from bpy.props import ( BoolProperty, - BoolVectorProperty, CollectionProperty, EnumProperty, FloatProperty, diff --git a/src/bonsai/bonsai/bim/module/project/workspace.py b/src/bonsai/bonsai/bim/module/project/workspace.py index 89467cbbee..1adb2045c2 100644 --- a/src/bonsai/bonsai/bim/module/project/workspace.py +++ b/src/bonsai/bonsai/bim/module/project/workspace.py @@ -172,4 +172,4 @@ class GenerateUVMap(bpy.types.Operator): return {"CANCELLED"} tool.Loader.load_generated_uv_map(obj.data) self.report({"INFO"}, "Generated UV map for selected mesh.") - return {"FINISHED"} \ No newline at end of file + return {"FINISHED"} From 1c456c3cb2e70fa3d064439af5b4c369a9c54c35 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 10:21:20 +0500 Subject: [PATCH 06/76] Bonsai Makefile - use official bpypolyskel repo instead of fork Since https://github.com/prochitecture/bpypolyskel/pull/22 got merged. --- src/bonsai/Makefile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 3cbfd8bdbf..f21f1b42ac 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -232,8 +232,7 @@ endif cd build/bonsai/bim/data/brick/ && wget https://github.com/BrickSchema/Brick/releases/download/nightly/Brick.ttl # Required for hipped roof generation - # TODO: Use official repo once https://github.com/prochitecture/bpypolyskel/pull/22 is merged. - cd build && . env/$(VENV_ACTIVATE) && $(PYTHON) -m pip wheel "git+https://github.com/Andrej730/bpypolyskel.git@pyproject_toml" --no-deps -w wheels/ + cd build && . env/$(VENV_ACTIVATE) && $(PYTHON) -m pip wheel "git+https://github.com/prochitecture/bpypolyskel" --no-deps -w wheels/ # folder for executable files mkdir -p build/bonsai/libs/bin From fb16e91249e63cca6e377296b5a6394d39cf806a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 10:59:39 +0500 Subject: [PATCH 07/76] Remove use of deprecated `datetime.utcnow()` To fix warnings below: ``` :1: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). ``` --- src/bonsai/bonsai/bim/export_ifc.py | 4 +--- .../ifcopenshell/api/project/create_file.py | 4 +--- src/ifcpatch/ifcpatch/recipes/PurgeData.py | 8 +------- src/opencdeserver/api/app/security/secure.py | 8 +++----- 4 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/bonsai/bonsai/bim/export_ifc.py b/src/bonsai/bonsai/bim/export_ifc.py index 633d6292f9..f28c106e95 100644 --- a/src/bonsai/bonsai/bim/export_ifc.py +++ b/src/bonsai/bonsai/bim/export_ifc.py @@ -72,9 +72,7 @@ class IfcExporter: def set_header(self): self.file.header.file_name.name = os.path.basename(self.ifc_export_settings.output_file) - self.file.header.file_name.time_stamp = ( - datetime.datetime.utcnow().replace(tzinfo=datetime.UTC).astimezone().replace(microsecond=0).isoformat() - ) + self.file.header.file_name.time_stamp = datetime.datetime.now().astimezone().replace(microsecond=0).isoformat() self.file.header.file_name.preprocessor_version = "IfcOpenShell {}".format(ifcopenshell.version) self.file.header.file_name.originating_system = "{} {}".format( self.get_application_name(), tool.Blender.get_bonsai_version() diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/create_file.py b/src/ifcopenshell-python/ifcopenshell/api/project/create_file.py index e122ba766b..a28f6dfa46 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/create_file.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/create_file.py @@ -53,9 +53,7 @@ def create_file(version: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4") -> ifcope """ file = ifcopenshell.file(schema=version) file.header.file_name.name = "/dev/null" # Hehehe - file.header.file_name.time_stamp = ( - datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).astimezone().replace(microsecond=0).isoformat() - ) + file.header.file_name.time_stamp = datetime.datetime.now().astimezone().replace(microsecond=0).isoformat() file.header.file_name.preprocessor_version = "IfcOpenShell {}".format(ifcopenshell.version) file.header.file_name.originating_system = "IfcOpenShell {}".format(ifcopenshell.version) file.header.file_name.authorization = "Nobody" diff --git a/src/ifcpatch/ifcpatch/recipes/PurgeData.py b/src/ifcpatch/ifcpatch/recipes/PurgeData.py index 3c34927a63..0ea968f48a 100644 --- a/src/ifcpatch/ifcpatch/recipes/PurgeData.py +++ b/src/ifcpatch/ifcpatch/recipes/PurgeData.py @@ -57,13 +57,7 @@ class Patcher: def patch(self): self.file.header.file_name.name = "Rabbit" - self.file.header.file_name.time_stamp = ( - datetime.datetime.utcnow() - .replace(tzinfo=datetime.timezone.utc) - .astimezone() - .replace(microsecond=0) - .isoformat() - ) + self.file.header.file_name.time_stamp = datetime.datetime.now().astimezone().replace(microsecond=0).isoformat() self.file.header.file_name.preprocessor_version = "Rabbit" self.file.header.file_name.originating_system = "Rabbit" diff --git a/src/opencdeserver/api/app/security/secure.py b/src/opencdeserver/api/app/security/secure.py index fec837b868..e332c94647 100644 --- a/src/opencdeserver/api/app/security/secure.py +++ b/src/opencdeserver/api/app/security/secure.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from database.neo4j import db from fastapi import Depends, HTTPException, Security, status @@ -33,10 +33,8 @@ credentials_exception = HTTPException( def create_access_token(data: dict, expires_delta: timedelta | None = None): payload = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=15) + expires_delta = expires_delta or timedelta(minutes=15) + expire = datetime.now(timezone.utc) + expires_delta payload.update({"expires": str(expire)}) encoded_jwt = jwt.encode(payload, secrets["security_secret_key"], algorithm=os.environ["SECURITY_ALGORITHM"]) return encoded_jwt From 5bfab569baade947ee62a810ea48f074249eecf3 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 13:55:57 +0500 Subject: [PATCH 08/76] bim.link_ifc - fix prop display in file dialog panel Fixes this - https://files.catbox.moe/eq10ip.png --- src/bonsai/bonsai/bim/module/project/operator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 9d0e2ff8d4..5d53995787 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1300,13 +1300,16 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): use_cache: bool def draw(self, context): + assert self.layout pprops = tool.Project.get_project_props() row = self.layout.row() row.prop(self, "use_relative_path") row = self.layout.row() row.prop(self, "use_cache") row = self.layout.row() - row.prop(pprops, "false_origin_mode") + row.label(text="False Origin Mode:") + row = self.layout.row() + row.prop(pprops, "false_origin_mode", text="") if pprops.false_origin_mode == "MANUAL": row = self.layout.row() row.prop(pprops, "false_origin") From f583d1ecc13251b5005f7f8f310e32b15b931f40 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 14:07:50 +0500 Subject: [PATCH 09/76] typing --- .../bonsai/bim/module/georeference/prop.py | 2 + .../bonsai/bim/module/project/operator.py | 168 ++++++++++++++---- 2 files changed, 132 insertions(+), 38 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/georeference/prop.py b/src/bonsai/bonsai/bim/module/georeference/prop.py index 41035398e9..da92edf4e6 100644 --- a/src/bonsai/bonsai/bim/module/georeference/prop.py +++ b/src/bonsai/bonsai/bim/module/georeference/prop.py @@ -267,6 +267,8 @@ class BIMGeoreferenceProperties(PropertyGroup): x_axis_ordinate: str x_axis_is_null: bool + model_is_georeferenced: bool + model_crs: str model_origin: str model_origin_si: str model_project_north: str diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 5d53995787..e900606587 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -178,9 +178,18 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper): bl_description = ( "Select an IFC file that can be used as a library.\n\nALT+click to reload the current loaded library file." ) - filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}) - append_all: bpy.props.BoolProperty(default=False) - use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) + filter_glob: bpy.props.StringProperty( + default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"} + ) # pyright: ignore[reportRedeclaration] + append_all: bpy.props.BoolProperty(default=False) # pyright: ignore[reportRedeclaration] + use_relative_path: bpy.props.BoolProperty( + name="Use Relative Path", default=False + ) # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + filter_glob: str + append_all: bool + use_relative_path: bool reload_previous_file = False @@ -558,7 +567,11 @@ class AppendEntireLibrary(bpy.types.Operator, tool.Ifc.Operator): class AppendLibraryElementByQuery(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.append_library_element_by_query" bl_label = "Append Library Element By Query" - query: bpy.props.StringProperty(name="Query") + + query: bpy.props.StringProperty(name="Query") # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + query: str @classmethod def poll(cls, context): @@ -587,9 +600,16 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): "Append element to the current project.\n\n" "ALT+CLICK to skip reusing materials, profiles, styles based on their name (may result in duplicates)" ) - definition: bpy.props.IntProperty() - prop_index: bpy.props.IntProperty() - assume_unique_by_name: bpy.props.BoolProperty(name="Assume Unique By Name", default=True, options={"SKIP_SAVE"}) + definition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + prop_index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + assume_unique_by_name: bpy.props.BoolProperty( + name="Assume Unique By Name", default=True, options={"SKIP_SAVE"} + ) # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + definition: int + prop_index: int + assume_unique_by_name: bool file: ifcopenshell.file @@ -939,24 +959,28 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): bl_label = "Load Project" bl_options = {"REGISTER", "UNDO"} bl_description = "Load an existing IFC project" - filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"}) - filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"}) - is_advanced: bpy.props.BoolProperty( + filepath: bpy.props.StringProperty( + subtype="FILE_PATH", options={"SKIP_SAVE"} + ) # pyright: ignore[reportRedeclaration] + filter_glob: bpy.props.StringProperty( + default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"} + ) # pyright: ignore[reportRedeclaration] + is_advanced: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] name="Enable Advanced Mode", description="Load IFC file with advanced settings. Checking this option will skip loading IFC file and will open advanced load settings", default=False, ) - use_relative_path: bpy.props.BoolProperty( + use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] name="Use Relative Path", description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved", default=False, ) - should_start_fresh_session: bpy.props.BoolProperty( + should_start_fresh_session: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] name="Should Start Fresh Session", description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option", default=True, ) - import_without_ifc_data: bpy.props.BoolProperty( + import_without_ifc_data: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] name="Import Without IFC Data", description=( "Import IFC objects as Blender objects without any IFC metadata and authoring capabilities." @@ -964,9 +988,20 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): ), default=False, ) - use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"}) + use_detailed_tooltip: bpy.props.BoolProperty( + default=False, options={"HIDDEN"} + ) # pyright: ignore[reportRedeclaration] filename_ext = ".ifc" + if TYPE_CHECKING: + filepath: str + filter_glob: str + is_advanced: bool + use_relative_path: bool + should_start_fresh_session: bool + import_without_ifc_data: bool + use_detailed_tooltip: bool + @classmethod def description(cls, context, properties): tooltip = cls.bl_description @@ -1265,7 +1300,10 @@ class ToggleFilterCategories(bpy.types.Operator): bl_idname = "bim.toggle_filter_categories" bl_label = "Toggle Filter Categories" bl_options = {"REGISTER", "UNDO"} - should_select: bpy.props.BoolProperty(name="Should Select", default=True) + should_select: bpy.props.BoolProperty(name="Should Select", default=True) # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + should_select: bool def execute(self, context): props = tool.Project.get_project_props() @@ -1355,7 +1393,11 @@ class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Unlink IFC" bl_options = {"REGISTER", "UNDO"} bl_description = "Remove the selected file from the link list" - link_index: bpy.props.IntProperty(name="Link Index") + + link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + link_index: int def _execute(self, context): props = tool.Project.get_project_props() @@ -1375,7 +1417,11 @@ class UnloadLink(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Unload Link" bl_options = {"REGISTER", "UNDO"} bl_description = "Unload the selected linked file" - link_index: bpy.props.IntProperty(name="Link Index") + + link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + link_index: int def _execute(self, context): link = tool.Project.get_project_props().links[self.link_index] @@ -1560,7 +1606,11 @@ class ReloadLink(bpy.types.Operator): bl_label = "Reload Link" bl_options = {"REGISTER", "UNDO"} bl_description = "Reload the selected file" - link_index: bpy.props.IntProperty(name="Link Index") + + link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + link_index: int def execute(self, context): bpy.ops.bim.unload_link(link_index=self.link_index) @@ -1572,7 +1622,11 @@ class ToggleLinkSelectability(bpy.types.Operator): bl_label = "Toggle Link Selectability" bl_options = {"REGISTER", "UNDO"} bl_description = "Toggle selectability" - link_index: bpy.props.IntProperty(name="Link Index") + + link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + link_index: int def execute(self, context): props = tool.Project.get_project_props() @@ -1742,7 +1796,11 @@ class SelectLinkHandle(bpy.types.Operator): bl_label = "Select Link Handle" bl_options = {"REGISTER", "UNDO"} bl_description = "Select link empty object handle" - link_index: bpy.props.IntProperty(name="Link Index") + + link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + link_index: int def execute(self, context): props = tool.Project.get_project_props() @@ -1800,11 +1858,28 @@ class ExportIFC(bpy.types.Operator, ExportHelper): bl_options = {"REGISTER", "UNDO"} filename_ext = ".ifc" supported_filexts = (".ifc", ".ifczip", ".ifcjson") - filter_glob: bpy.props.StringProperty(default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"}) - json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version") - json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False) - should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"}) - use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) + filter_glob: bpy.props.StringProperty( + default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"} + ) # pyright: ignore[reportRedeclaration] + json_version: bpy.props.EnumProperty( + items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version" + ) # pyright: ignore[reportRedeclaration] + json_compact: bpy.props.BoolProperty( + name="Export Compact IFCJSON", default=False + ) # pyright: ignore[reportRedeclaration] + should_save_as: bpy.props.BoolProperty( + name="Should Save As", default=False, options={"HIDDEN"} + ) # pyright: ignore[reportRedeclaration] + use_relative_path: bpy.props.BoolProperty( + name="Use Relative Path", default=False + ) # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + filter_glob: str + json_version: str + json_compact: bool + should_save_as: bool + use_relative_path: bool @classmethod def poll(cls, context): @@ -2449,7 +2524,7 @@ class EnableCulling(bpy.types.Operator): self.total_mousemoves = 0 self.cullable_objects = [] - def modal(self, context, event): + def modal(self, context, event) -> set["rna_enums.OperatorReturnItems"]: if not LinksData.enable_culling: for obj in bpy.context.visible_objects: if obj.type == "MESH" and obj.name.startswith("Ifc"): @@ -2480,7 +2555,7 @@ class EnableCulling(bpy.types.Operator): return {"PASS_THROUGH"} - def is_view_changed(self, context): + def is_view_changed(self, context: bpy.types.Context) -> bool: view_matrix = context.region_data.view_matrix projection_matrix = context.region_data.window_matrix vp_matrix = projection_matrix @ view_matrix @@ -2495,7 +2570,7 @@ class EnableCulling(bpy.types.Operator): return True return False - def is_object_in_view(self, obj, context, camera_position): + def is_object_in_view(self, obj: bpy.types.Object, context: bpy.types.Context, camera_position: Vector) -> bool: # Get the view matrix and the projection matrix from the active viewport view_matrix = context.region_data.view_matrix projection_matrix = context.region_data.window_matrix @@ -2522,7 +2597,7 @@ class EnableCulling(bpy.types.Operator): return False return True - def invoke(self, context, event): + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set["rna_enums.OperatorReturnItems"]: LinksData.enable_culling = True self.cullable_objects = [] for obj in bpy.context.visible_objects: @@ -2809,8 +2884,16 @@ class IFCFileHandlerOperator(bpy.types.Operator): bl_label = "Import .ifc file" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - directory: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"}) - files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"}) + directory: bpy.props.StringProperty( + subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"} + ) # pyright: ignore[reportRedeclaration] + files: bpy.props.CollectionProperty( + type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"} + ) # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + directory: str + files: list[bpy.types.OperatorFileListElement] def invoke(self, context, event): # Keeping code in .invoke() as we'll probably add some @@ -2861,7 +2944,10 @@ class MeasureTool(bpy.types.Operator, PolylineOperator): bl_label = "Measure Tool" bl_options = {"REGISTER", "UNDO"} - measure_type: bpy.props.StringProperty() + measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + measure_type: str @classmethod def poll(cls, context): @@ -2957,7 +3043,10 @@ class MeasureFaceAreaTool(bpy.types.Operator, PolylineOperator): bl_label = "Measure Face Area Tool" bl_options = {"REGISTER", "UNDO"} - measure_type: bpy.props.StringProperty() + measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + measure_type: str @classmethod def poll(cls, context): @@ -3165,7 +3254,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): super().invoke(context, event) return {"RUNNING_MODAL"} - def cancel_tool(self, context): + def cancel_tool(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]: context.workspace.status_text_set(text=None) if hasattr(self, "tool_state"): self.tool_state.plane_method = None @@ -3173,7 +3262,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): tool.Blender.update_viewport() return {"CANCELLED"} - def handle_custom_instructions(self, context): + def handle_custom_instructions(self, context: bpy.types.Context) -> None: if len(self.selected_points) == 0: instruction_text = "Click First Point on Image" elif len(self.selected_points) == 1: @@ -3188,14 +3277,14 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): context.workspace.status_text_set(text=instruction_text) - def calculate_distance(self): + def calculate_distance(self) -> None: if len(self.selected_points) == 2: point1 = self.selected_points[0] point2 = self.selected_points[1] distance_3d = (point2 - point1).length self.calculated_distance = distance_3d / self.unit_scale - def apply_scaling(self, context): + def apply_scaling(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]: if len(self.selected_points) != 2: self.report({"ERROR"}, "Two points must be selected") return {"CANCELLED"} @@ -3253,7 +3342,10 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator): bl_idname = "bim.load_blend_metadata_and_ifc" bl_label = "Load Blend Metadata and IFC" bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(name="IFC File Path", default="") + filepath: bpy.props.StringProperty(name="IFC File Path", default="") # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + filepath: str def execute(self, context): ifc_file = self.filepath From bd15ba4aa3c9b627160c4b8f527dcfd0f579ff3a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 14:18:01 +0500 Subject: [PATCH 10/76] Linked Models - fix removing link operator missing if link is still loaded --- src/bonsai/bonsai/bim/module/project/ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 98122100d2..dfe8e44987 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -496,7 +496,7 @@ class BIM_PT_links(Panel): row.operator("bim.reload_link", text="", icon="FILE_REFRESH").link_index = index else: row.operator("bim.load_link", text="", icon="LINKED").link_index = index - row.operator("bim.unlink_ifc", text="", icon="X").link_index = index + row.operator("bim.unlink_ifc", text="", icon="X").link_index = index self.layout.template_list("BIM_UL_links", "", self.props, "links", self.props, "active_link_index") if LinksData.enable_culling: From 63a8639353c7fa2d047dabd6d087d1b18d97b861 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 14:25:25 +0500 Subject: [PATCH 11/76] Linked Models - option to provide custom selector query Available in file dialog when linking model - https://files.catbox.moe/tdmmbt.png It's not very robust currently, just something to start with. --- .../bonsai/bim/module/project/operator.py | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index e900606587..9494bf5d18 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1327,6 +1327,14 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): default=False, ) use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) + query: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration] + name="Query", + description="Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n" + "Currently caching with custom queries is not supported and model will be reloaded each time. " + "Also if model was previously loaded with query, " + "you may need to avoid using previous cache to load it wihout query.", + ) + filename_ext = ".ifc" if TYPE_CHECKING: @@ -1336,6 +1344,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): filter_glob: str use_relative_path: bool use_cache: bool + query: str def draw(self, context): assert self.layout @@ -1353,6 +1362,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): row.prop(pprops, "false_origin") row = self.layout.row() row.prop(pprops, "project_north") + self.layout.prop(self, "query", placeholder="IfcElement") def _execute(self, context): start = time.time() @@ -1385,7 +1395,10 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): new.ifc_definition_id = reference.id() new.name = filepath new.filepath = filepath - bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache) + # TODO: currently we don't detect the previous query model was loaded with + # so if query is provided, cache is ignored. + use_cache = self.use_cache or bool(self.query) + bpy.ops.bim.load_link(link_index=-1, use_cache=use_cache, query=self.query) class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator): @@ -1446,10 +1459,12 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) # pyright: ignore[reportRedeclaration] + query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] if TYPE_CHECKING: link_index: int use_cache: bool + query: str def _execute(self, context): self.link = tool.Project.get_project_props().links[self.link_index] @@ -1520,7 +1535,7 @@ def run(): pprops.project_north = "{pprops.project_north}" # Use absolute path to be safe from cwd changes. try: - bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}") + bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}", query={repr(self.query)}) except RuntimeError as e: # Operator failed (returned CANCELLED with error report) print(f"Failed to load linked project: {{e}}") @@ -2029,6 +2044,12 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): bl_description = "Operator is used to load a project .cache.blend to then link it to the IFC file." bl_options = {"REGISTER", "UNDO"} + query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + """See ``bim.link_ifc``.""" + + if TYPE_CHECKING: + query: str + file: ifcopenshell.file meshes: dict[str, bpy.types.Mesh] # Material names is derived from diffuse as in 'r-g-b-a'. @@ -2078,14 +2099,17 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): tool.Loader.settings.context_settings = tool.Loader.create_settings() tool.Loader.settings.gross_context_settings = tool.Loader.create_settings(is_gross=True) - self.elements = set(self.file.by_type("IfcElement")) - if self.file.schema in ("IFC2X3", "IFC4"): - self.elements |= set(self.file.by_type("IfcProxy")) - if self.file.schema == "IFC2X3": - self.elements |= set(self.file.by_type("IfcSpatialStructureElement")) + if self.query: + self.elements = ifcopenshell.util.selector.filter_elements(self.file, self.query) else: - self.elements |= set(self.file.by_type("IfcSpatialElement")) - self.elements -= set(self.file.by_type("IfcFeatureElement")) + self.elements = set(self.file.by_type("IfcElement")) + if self.file.schema in ("IFC2X3", "IFC4"): + self.elements |= set(self.file.by_type("IfcProxy")) + if self.file.schema == "IFC2X3": + self.elements |= set(self.file.by_type("IfcSpatialStructureElement")) + else: + self.elements |= set(self.file.by_type("IfcSpatialElement")) + self.elements -= set(self.file.by_type("IfcFeatureElement")) if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin: tool.Loader.set_manual_blender_offset(self.file) From 35e3d9c42eaf2287b3f117b74ced07a205099cbf Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 14:31:23 +0500 Subject: [PATCH 12/76] Linked Models - invalidate cache for mismatching query automatically --- .../bonsai/bim/module/project/operator.py | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 9494bf5d18..c6e90ed7ee 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1329,10 +1329,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) query: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration] name="Query", - description="Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n" - "Currently caching with custom queries is not supported and model will be reloaded each time. " - "Also if model was previously loaded with query, " - "you may need to avoid using previous cache to load it wihout query.", + description="Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.", ) filename_ext = ".ifc" @@ -1395,10 +1392,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): new.ifc_definition_id = reference.id() new.name = filepath new.filepath = filepath - # TODO: currently we don't detect the previous query model was loaded with - # so if query is provided, cache is ignored. - use_cache = self.use_cache or bool(self.query) - bpy.ops.bim.load_link(link_index=-1, use_cache=use_cache, query=self.query) + bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query) class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator): @@ -1506,8 +1500,20 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): def link_ifc(self) -> Union[set[str], None]: blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend") h5_filepath = self.filepath_.with_suffix(".ifc.cache.h5") + json_filepath = self.filepath_.with_suffix(".ifc.cache.json") - if not self.use_cache and blend_filepath.exists(): + def should_clear_cache() -> bool: + if not self.use_cache: + return True + if not blend_filepath.exists(): + return False + data = json.loads(json_filepath.read_text()) + # Empty 'query' - model loaded without custom query. + # Missing 'query' - model was loaded before custom queries were introduced in Bonsai. + query = data.get("query", "") + return query != self.query + + if should_clear_cache(): os.remove(blend_filepath) if not blend_filepath.exists(): @@ -2134,6 +2140,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): "false_origin_mode": pprops.false_origin_mode, "false_origin": pprops.false_origin, "project_north": pprops.project_north, + "query": self.query, } with open(self.json_filepath, "w") as f: json.dump(data, f) From 7e987be00f6bd509d36948ef58e4fafb98263e65 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 14:46:34 +0500 Subject: [PATCH 13/76] bim.link_ifc - document default query To make it more discoverable for users. --- src/bonsai/bonsai/bim/module/project/operator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index c6e90ed7ee..c017336cbc 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1329,7 +1329,10 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) query: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration] name="Query", - description="Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.", + description=( + "Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n" + "Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement." + ), ) filename_ext = ".ifc" From 4473dbd138114fff17d3080fbf9c03ccc4b9cbce Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 15:17:15 +0500 Subject: [PATCH 14/76] bim.generate_uv_map - move to operator.py, fix missing description, add separate row in ui --- .../bonsai/bim/module/project/__init__.py | 2 +- .../bonsai/bim/module/project/operator.py | 16 ++++++++++++++ .../bonsai/bim/module/project/workspace.py | 21 ++----------------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index 67710db8ba..e83da49c5b 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -76,7 +76,7 @@ classes = ( operator.UnlinkIfc, operator.UnloadLink, workspace.ExploreHotkey, - workspace.GenerateUVMap, + operator.GenerateUVMap, prop.LibraryBreadcrumb, prop.LibraryElement, prop.FilterCategory, diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index c017336cbc..4e11a8ead8 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -3413,3 +3413,19 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator): bpy.app.handlers.load_post.append(load_handler) bpy.ops.wm.open_mainfile(filepath=metadata_path) return {"FINISHED"} + + +class GenerateUVMap(bpy.types.Operator): + bl_idname = "bim.generate_uv_map" + bl_label = "Generate UV Map" + bl_description = "Generate UV map for selected mesh." + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + if not obj or not isinstance(obj.data, bpy.types.Mesh): + self.report({"ERROR"}, "No valid mesh selected.") + return {"CANCELLED"} + tool.Loader.load_generated_uv_map(obj.data) + self.report({"INFO"}, "Generated UV map for selected mesh.") + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/project/workspace.py b/src/bonsai/bonsai/bim/module/project/workspace.py index 1adb2045c2..850fdc6531 100644 --- a/src/bonsai/bonsai/bim/module/project/workspace.py +++ b/src/bonsai/bonsai/bim/module/project/workspace.py @@ -87,8 +87,8 @@ class ExploreTool(bpy.types.WorkSpaceTool): op.hotkey = "S_S" op.description = "Scale Image Annotation. Allows to scale an IfcReferenceImage. Select image, select tool. Check lower left corner instructions to select two points and provide real distance between them" - op = row.operator("bim.generate_uv_map", text="Generate UV Map", icon="UV") - op.description = "Generate UV map for selected mesh." + row = layout.row(align=True) + row.operator("bim.generate_uv_map", icon="UV") class ExploreHotkey(bpy.types.Operator): @@ -156,20 +156,3 @@ class ExploreHotkey(bpy.types.Operator): def hotkey_A_H(self) -> None: bpy.ops.bim.hide_queried_linked_element(unhide_all=True) - - -class GenerateUVMap(bpy.types.Operator): - bl_idname = "bim.generate_uv_map" - bl_label = "Generate UV Map" - bl_options = {"REGISTER", "UNDO", "INTERNAL"} - - description: bpy.props.StringProperty() - - def execute(self, context): - obj = context.active_object - if not obj or not hasattr(obj, "data") or not hasattr(obj.data, "polygons"): - self.report({"ERROR"}, "No valid mesh selected.") - return {"CANCELLED"} - tool.Loader.load_generated_uv_map(obj.data) - self.report({"INFO"}, "Generated UV map for selected mesh.") - return {"FINISHED"} From bf75a1964047ceb8f6be639875109c2e968f2ecd Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 15:24:42 +0500 Subject: [PATCH 15/76] bim.image_scaling_tool - break description to multiple lines for readibility --- src/bonsai/bonsai/bim/module/project/workspace.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/project/workspace.py b/src/bonsai/bonsai/bim/module/project/workspace.py index 850fdc6531..f087d47fa1 100644 --- a/src/bonsai/bonsai/bim/module/project/workspace.py +++ b/src/bonsai/bonsai/bim/module/project/workspace.py @@ -85,7 +85,12 @@ class ExploreTool(bpy.types.WorkSpaceTool): row = layout.row(align=True) op = row.operator("bim.explore_hotkey", text="Image Scaling Tool", icon="IMAGE_PLANE") op.hotkey = "S_S" - op.description = "Scale Image Annotation. Allows to scale an IfcReferenceImage. Select image, select tool. Check lower left corner instructions to select two points and provide real distance between them" + op.description = ( + "Scale Image Annotation.\n\n" + "Allows to scale an IfcReferenceImage.\n\n" + "Select image, select tool. " + "Check lower left corner instructions to select two points and provide real distance between them" + ) row = layout.row(align=True) row.operator("bim.generate_uv_map", icon="UV") From 025fb769e2aaf0ef16bc05c6c7564a58e8dd1627 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 15:24:32 +0500 Subject: [PATCH 16/76] bim.explore_tool - remove additional row to keep hotkey and operators on the same row --- src/bonsai/bonsai/bim/module/project/workspace.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/workspace.py b/src/bonsai/bonsai/bim/module/project/workspace.py index f087d47fa1..bd60f4975a 100644 --- a/src/bonsai/bonsai/bim/module/project/workspace.py +++ b/src/bonsai/bonsai/bim/module/project/workspace.py @@ -71,18 +71,15 @@ class ExploreTool(bpy.types.WorkSpaceTool): row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_M") - row = layout.row(align=True) op = row.operator("bim.explore_hotkey", text="Measure Tool", icon="CON_DISTLIMIT") op.hotkey = "S_M" row = layout.row(align=True) row.prop(prop, "measurement_type", text="Measure Type", expand=True, icon_only=True, emboss=True) - row = layout.row(align=True) op = row.operator("bim.clear_measurement", text="", icon="X") row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_S") - row = layout.row(align=True) op = row.operator("bim.explore_hotkey", text="Image Scaling Tool", icon="IMAGE_PLANE") op.hotkey = "S_S" op.description = ( From ba36dc82ff1213a093e1627be70c4b4b28f97053 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 16 Mar 2026 15:27:10 +0500 Subject: [PATCH 17/76] bim.clear_measurement - add poll message --- src/bonsai/bonsai/bim/module/project/operator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 4e11a8ead8..e674cb56f4 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -3184,7 +3184,10 @@ class ClearMeasurement(bpy.types.Operator): @classmethod def poll(cls, context): polyline_props = tool.Model.get_polyline_props() - return len(polyline_props.measurement_polyline) > 0 + if len(polyline_props.measurement_polyline) > 0: + return True + cls.poll_message_set("No measurement to clear.") + return False def execute(self, context): polyline_props = tool.Model.get_polyline_props() From b8d3d1d1051b1ab1452fafcfee4d9f60c375d810 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 17 Mar 2026 13:09:58 +0500 Subject: [PATCH 18/76] pyproject.toml - add `ty` command to check for deprecated methods --- pyproject.toml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 7234815c08..895e74679e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,17 @@ ignore = [ "UP032", # Replace .format with f-string ] +[tool.ty.rules] +# We only use `ty` currently to check for deprecated methods. +all = "ignore" +deprecated = "error" + +[tool.ty.src] +exclude = [ + "src/ifc2ca/templates", + "src/svgfill/3rdparty", +] + [tool.poe.tasks] ruff-main = "ruff check --extend-exclude nix/build-all.py" @@ -87,6 +98,8 @@ ruff.sequence = ["ruff-main", "ruff-old"] black = "black ." +ty = "ty check" + format.sequence = ["black", "ruff-main", "ruff-old"] cmake-format = "gersemi . --in-place" From 515fe8d2ef4d82d4363ea15627299c7e585c2445 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 17 Mar 2026 13:28:11 +0500 Subject: [PATCH 19/76] Remove use of deprecated `tempfile.mktemp` --- src/bonsai/test/tool/test_project.py | 2 +- src/ifcpatch/test/test_ifcpatch.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bonsai/test/tool/test_project.py b/src/bonsai/test/tool/test_project.py index ae0a187080..e9e11f4656 100644 --- a/src/bonsai/test/tool/test_project.py +++ b/src/bonsai/test/tool/test_project.py @@ -365,7 +365,7 @@ class TestLoadingIfcSqlite(NewFile): sql_type="SQLite", ) patcher.patch() - tmp_file = Path(tempfile.mktemp(suffix=".ifcsqlite")) + tmp_file = Path(tempfile.mkstemp(suffix=".ifcsqlite")[1]) ifcpatch.write(patcher.get_output(), tmp_file) elements_with_meshes = [ diff --git a/src/ifcpatch/test/test_ifcpatch.py b/src/ifcpatch/test/test_ifcpatch.py index 4d7e98e55a..d0976965b6 100644 --- a/src/ifcpatch/test/test_ifcpatch.py +++ b/src/ifcpatch/test/test_ifcpatch.py @@ -52,10 +52,10 @@ class Test: assert output.by_type("IfcProject")[0].GlobalId == project.GlobalId assert output.by_type("IfcWall")[0].GlobalId == wall.GlobalId - output_path = Path(tempfile.mktemp()) + output_path = Path(tempfile.mkstemp()[1]) try: - assert not output_path.exists() + assert output_path.stat().st_size == 0 ifcpatch.write(patcher.get_output(), output_path) - assert output_path.exists() + assert output_path.stat().st_size != 0 finally: output_path.unlink() From c36e7badae3a64aa1d8387ab4b40ddbbd7316830 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 17 Mar 2026 13:39:10 +0500 Subject: [PATCH 20/76] Remove use of deprecated `os.popen` --- choco/bonsai/choco_release.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/choco/bonsai/choco_release.py b/choco/bonsai/choco_release.py index 681e6c58e5..ea53798c5d 100644 --- a/choco/bonsai/choco_release.py +++ b/choco/bonsai/choco_release.py @@ -13,6 +13,7 @@ import hashlib import os import pathlib import re +import subprocess from typing import NoReturn from urllib import request @@ -20,7 +21,7 @@ from github import Github def get_repo_tag_names() -> list[str]: - git_return = os.popen("git tag -l").read() + git_return = subprocess.check_output("git tag -l", text=True) tag_names = [tag_name for tag_name in git_return.split("\n") if tag_name] print(f"{len(tag_names)} tag_names found in repo") return tag_names @@ -78,6 +79,10 @@ def get_release_zip(tag: str) -> tuple[str, str]: raise Exception(f"Couldn't find the release matching '{python_version}' and '{TARGET_OS}' in tag '{tag}'.") +def run(command: str) -> None: + subprocess.check_output(command) + + start = datetime.datetime.now() URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender" @@ -97,7 +102,7 @@ should_release = False target_release_tag = "" TARGET_OS = "windows-x64" -git_status = os.popen("git status").read() +git_status = subprocess.check_output("git status", text=True) print(git_status) for tag_name in get_repo_tag_names(): @@ -147,7 +152,7 @@ blenderbim_build_version = target_release_tag.replace("blenderbim-", "") # url_blenderbim_py3x_win_zip release_zip_file_name, url_blenderbim_py3x_win_zip = get_release_zip(target_release_tag) -os.popen(f"wget {url_blenderbim_py3x_win_zip} --no-verbose").read() +subprocess.check_call(f"wget {url_blenderbim_py3x_win_zip} --no-verbose") # sha256sum_blenderbim_py310_win_zip sha256sum_blenderbim_py3x_win_zip = get_file_sha256_hash(release_zip_file_name) @@ -201,13 +206,13 @@ print("[INFO] inserting dynamic chocolatey package parameters successful") print("\n_____ build choco.exe with mono") choco_version = "1.1.0" -os.popen(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet").read() -os.popen(f"tar -xzf {choco_version}.tar.gz").read() +run(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet") +run(f"tar -xzf {choco_version}.tar.gz") print("choco tar unpack successful") os.chdir("choco-1.1.0") -os.popen("./build.sh").read() +run("./build.sh") -os.popen("cp -r build_output/chocolatey /opt/chocolatey").read() +run("cp -r build_output/chocolatey /opt/chocolatey") os.chdir(BLENDERBIM_DIR) if pathlib.Path("/opt/chocolatey/choco.exe").exists(): @@ -215,11 +220,15 @@ if pathlib.Path("/opt/chocolatey/choco.exe").exists(): print("\n_____ build choco pack") -os.popen("mono /opt/chocolatey/choco.exe pack --allow-unofficial").read() -os.popen('mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial').read() +run("mono /opt/chocolatey/choco.exe pack --allow-unofficial") +run( + 'mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial' +) print("\n_____ build choco push") -os.popen('mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose').read() +run( + 'mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose' +) print(f"choco push of version: {target_release_tag} successful!") print(f"it took: {datetime.datetime.now() - start}") From a51b2c587c0d7bd956add9af81d7428a9b71c050 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Tue, 17 Mar 2026 20:49:48 +0100 Subject: [PATCH 21/76] Revert "Simplifies IfxAxis2PlacementLinear, assumes default Axis = (0,0,1)" This reverts commit cf1552e79e4f80eb431e1e2eae179c1ef30496e5. --- .../mapping/IfcAxis2PlacementLinear.cpp | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/ifcgeom/mapping/IfcAxis2PlacementLinear.cpp b/src/ifcgeom/mapping/IfcAxis2PlacementLinear.cpp index f86f59ac98..b11b61b46b 100644 --- a/src/ifcgeom/mapping/IfcAxis2PlacementLinear.cpp +++ b/src/ifcgeom/mapping/IfcAxis2PlacementLinear.cpp @@ -29,8 +29,10 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcAxis2PlacementLinear* inst) Logger::Error(std::runtime_error("Location must be IfcPointByDistanceExpression for IfcAxis2PlacementLinear")); } + Eigen::Vector3d o, axis(0, 0, 1), refDirection; + taxonomy::matrix4::ptr m = taxonomy::cast(map(inst->Location())); - Eigen::Vector3d o = m->components().col(3).head<3>(); + o = m->components().col(3).head<3>(); // From 8.9.3.4 IfcAxis2PlacementLinear there are 4 cases that need to be considered // 1) Axis is given but not RefDirection @@ -38,12 +40,43 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcAxis2PlacementLinear* inst) // 3) Neither Axis or RefDirection are provided // 4) Both Axis and RefDirection are provided - Eigen::Vector3d z = inst->Axis() ? *taxonomy::cast(map(inst->Axis()))->components_ : Eigen::Vector3d(0,0,1); // Axis is (0,0,1) when omitted - Eigen::Vector3d rd = inst->RefDirection() ? *taxonomy::cast(map(inst->RefDirection()))->components_ : m->components().col(0).head<3>(); // RefDirection is the curve tangent when omitted - Eigen::Vector3d y = z.cross(rd); - Eigen::Vector3d x = y.cross(z); + const bool hasAxis = inst->Axis() != nullptr; + const bool hasRef = inst->RefDirection() != nullptr; - return taxonomy::make(o, z, x); + /* + if (hasAxis != hasRef) { + Logger::Warning("Axis and RefDirection should be specified together", inst); + } + */ + + if (hasAxis && !hasRef) { + taxonomy::direction3::ptr a = taxonomy::cast(map(inst->Axis())); + axis = *a->components_; + + refDirection = m->components().col(0).head<3>(); // RefDirection is the curve tangent when omitted + // refDirection is not necessarily orthogonal to axis. + // axis.cross(refDirection) gives y. y.cross(axis) gives x=refDirection + refDirection = axis.cross(refDirection).cross(axis); + } else if (!hasAxis && hasRef) { + taxonomy::direction3::ptr r = taxonomy::cast(map(inst->RefDirection())); + refDirection = *r->components_; + Eigen::Vector3d up(0, 0, 1); + axis = refDirection.cross(up.cross(refDirection)); + } else if (!hasAxis && !hasRef) { + refDirection = m->components().col(0).head<3>(); // RefDirection is the curve tangent when omitted + Eigen::Vector3d up(0, 0, 1); + axis = refDirection.cross(up.cross(refDirection)); + } else { + taxonomy::direction3::ptr a = taxonomy::cast(map(inst->Axis())); + axis = *a->components_; + + taxonomy::direction3::ptr r = taxonomy::cast(map(inst->RefDirection())); + refDirection = *r->components_; + refDirection = axis.cross(refDirection).cross(axis); // refDirection needs to be orthogonal to axis + } + + // axis and refDirection need to be orthogonal + return taxonomy::make(o, axis, refDirection); } #endif From c33509364cb8feaedf425d1ecd3c69fa30886d80 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 10:04:15 +0500 Subject: [PATCH 22/76] Fix error generating ifcpatch recipes docs for Bonsai tooltips Mentioned in https://github.com/IfcOpenShell/IfcOpenShell/issues/7667#issuecomment-4076645173 Traceback: ``` Traceback (most recent call last): File "\bonsai\bim\module\patch\prop.py", line 55, in get_ifcpatch_recipes docs = ifcpatch.extract_docs(f, "Patcher", "__init__", ("src", "file", "logger", "args")) File "\ifcpatch\__init__.py", line 168, in extract_docs spec.loader.exec_module(submodule) ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^ File "", line 1027, in exec_module File "", line 488, in _call_with_frames_removed File "\ifcpatch/recipes/FixRevit2025TINs.py", line 31, in class Patcher: ...<509 lines>... return co / self.unit_scale File "\ifcpatch/recipes/FixRevit2025TINs.py", line 168, in Patcher def create_edges(self, obj: bpy.types.Object) -> None: ^^^ NameError: name 'bpy' is not defined File "\bonsai\bim\module\patch\prop.py", line 43, in get_ifcpatch_recipes ``` --- src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py b/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py index 4045e86aad..5d4844e779 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py +++ b/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcPatch. If not, see . +from __future__ import annotations import logging from typing import Optional, TYPE_CHECKING From 7bdc1b6a7530017a6f5ed22fb18d3f3bc1ba270e Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 10:57:36 +0500 Subject: [PATCH 23/76] cache_dependencies - skip ifcopenshell dir when packing --- nix/cache_dependencies.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nix/cache_dependencies.py b/nix/cache_dependencies.py index 465d6002f1..3d115764b4 100644 --- a/nix/cache_dependencies.py +++ b/nix/cache_dependencies.py @@ -41,6 +41,9 @@ def pack_dependencies(install_dir: Path) -> None: if not dependency_path.is_dir(): continue dependency_name = dependency_path.name + # Skip ifcopenshell - it's a build output, not a dependency to reuse across builds. + if dependency_name == "ifcopenshell": + continue tar_path = install_dir / f"{CACHE_PREFIX}{dependency_name}.tar.gz" if tar_path.exists(): print(f"Skipping existing cache: '{tar_path}'") From 05bf2b82d2091957dbce9568c070d399847da1a8 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 10:21:12 +0500 Subject: [PATCH 24/76] system.disconnect_port - fix missing flow direction reset (bbda8d2) --- .../ifcopenshell/api/system/disconnect_port.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py b/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py index b6b72a192b..7ae790e753 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py @@ -65,6 +65,8 @@ def disconnect_port(file: ifcopenshell.file, port: ifcopenshell.entity_instance) rels += port.ConnectedFrom or () for rel in rels: + rel.RelatingPort.FlowDirection = None + rel.RelatedPort.FlowDirection = None history = rel.OwnerHistory file.remove(rel) if history: From c03156b5cdfb8f3eb1d2be35ca4b61fdaeb21be7 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 10:27:07 +0500 Subject: [PATCH 25/76] control.assign_control - remove deprecated related_object argument support --- .../ifcopenshell/api/__init__.py | 7 +--- src/ifcopenshell-python/test/api/test_api.py | 35 ------------------- 2 files changed, 1 insertion(+), 41 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/__init__.py index 49c3b471dd..f485e865fb 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/__init__.py @@ -42,7 +42,6 @@ import importlib import inspect import json from collections.abc import Callable -from functools import partial from typing import TYPE_CHECKING, Any, Optional import numpy @@ -90,11 +89,7 @@ def renamed_arguments_deprecation( # "group.add_group": partial( # renamed_arguments_deprecation, arguments_remapped={"Name": "name", "Description": "description"} # ), -ARGUMENTS_DEPRECATION: dict[str, Callable[[str, dict[str, Any]], tuple[str, dict[str, Any]]]] = { - "control.assign_control": partial( - batching_argument_deprecation, prev_argument="related_object", new_argument="related_objects" - ), -} +ARGUMENTS_DEPRECATION: dict[str, Callable[[str, dict[str, Any]], tuple[str, dict[str, Any]]]] = {} CACHED_USECASE_CLASSES: dict[str, Callable] = {} diff --git a/src/ifcopenshell-python/test/api/test_api.py b/src/ifcopenshell-python/test/api/test_api.py index 7036f93528..8bed51a56f 100644 --- a/src/ifcopenshell-python/test/api/test_api.py +++ b/src/ifcopenshell-python/test/api/test_api.py @@ -15,38 +15,3 @@ # # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . - -from datetime import datetime - -import ifcopenshell.api.control -import ifcopenshell.api.cost -import ifcopenshell.api.root -import ifcopenshell.util.element -import test.bootstrap - - -def deprecation_check(test): - def new_test(self): - assert datetime.now().date() < datetime(2026, 1, 9).date(), "API arguments are completely deprecated" - test(self) - - return new_test - - -class TestTemporarySupportForDeprecatedAPIArguments(test.bootstrap.IFC4): - @deprecation_check - def test_assigning_control(self): - model = self.file - element = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall") - control = ifcopenshell.api.cost.add_cost_schedule(model) - ifcopenshell.api.control.assign_control(model, relating_control=control, related_objects=[element]) - assert list(ifcopenshell.util.element.get_controls(element)) == [control] - - @deprecation_check - def test_unassigning_control(self): - TestTemporarySupportForDeprecatedAPIArguments.test_assigning_control(self) - model = self.file - element = model.by_type("IfcWall")[0] - control = model.by_type("IfcCostSchedule")[0] - ifcopenshell.api.control.unassign_control(model, relating_control=control, related_objects=[element]) - assert list(ifcopenshell.util.element.get_controls(element)) == [] From 888158570aa04baed70688be8ff630da11af535f Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 11:18:55 +0500 Subject: [PATCH 26/76] Remove Python 3.9 references --- .github/workflows/ci-ifcopenshell-python-pypi.yml | 2 +- .github/workflows/ci-ifcopenshell-python.yml | 2 +- src/ifcopenshell-python/Makefile | 3 --- src/ifcopenshell-python/test/test_package.py | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-ifcopenshell-python-pypi.yml b/.github/workflows/ci-ifcopenshell-python-pypi.yml index 526aa09b64..7988a0bc6a 100644 --- a/.github/workflows/ci-ifcopenshell-python-pypi.yml +++ b/.github/workflows/ci-ifcopenshell-python-pypi.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - pyver: [py39, py310, py311, py312, py313, py314] + pyver: [py310, py311, py312, py313, py314] config: - { name: "Windows 64bit", diff --git a/.github/workflows/ci-ifcopenshell-python.yml b/.github/workflows/ci-ifcopenshell-python.yml index 5d34335ada..fe99f4857b 100644 --- a/.github/workflows/ci-ifcopenshell-python.yml +++ b/.github/workflows/ci-ifcopenshell-python.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - pyver: [py39, py310, py311, py312, py313, py314] + pyver: [py310, py311, py312, py313, py314] config: - { name: "Windows 64bit", diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 90c503d145..11fa50887e 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -28,9 +28,6 @@ endif endif # TODO: we should simplify this at some point... -ifeq ($(PYVERSION), py39) -PYNUMBER:=39 -endif ifeq ($(PYVERSION), py310) PYNUMBER:=310 endif diff --git a/src/ifcopenshell-python/test/test_package.py b/src/ifcopenshell-python/test/test_package.py index b251e081c7..c7ed42a26a 100644 --- a/src/ifcopenshell-python/test/test_package.py +++ b/src/ifcopenshell-python/test/test_package.py @@ -32,7 +32,7 @@ except: # - .github/workflows/ci-ifcopenshell-python.yml # - .github/workflows/ci-ifcopenshell-python-pypi.yml # - src/ifcopenshell-python/Makefile (PYVERSION check) -SUPPORTED_PY_VERSIONS = ("39", "310", "311", "312", "313", "314") +SUPPORTED_PY_VERSIONS = ("310", "311", "312", "313", "314") SUPPORTED_PLATFORMS = ("win64", "linux64", "macos64", "macosm164") WASM_SUPPORTED_PY_VERSIONS = ("313",) From f0b27a091037840cbb86b366562432b3c2df8930 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 11:37:50 +0500 Subject: [PATCH 27/76] ifcopenshell-python Makefile - simplify pyversion check, similar to 6409f41 --- src/ifcopenshell-python/Makefile | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 11fa50887e..b814cb3163 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -27,26 +27,15 @@ SED:=sed -i '' -e endif endif -# TODO: we should simplify this at some point... -ifeq ($(PYVERSION), py310) -PYNUMBER:=310 -endif -ifeq ($(PYVERSION), py311) -PYNUMBER:=311 -endif -ifeq ($(PYVERSION), py312) -PYNUMBER:=312 -endif -ifeq ($(PYVERSION), py313) -PYNUMBER:=313 -endif -ifeq ($(PYVERSION), py314) -PYNUMBER:=314 -endif -ifndef PYNUMBER -$(error Unsupported PYVERSION '$(PYVERSION)') +SUPPORTED_PYVERSIONS := py310 py311 py312 py313 py314 + +ifeq ($(filter $(PYVERSION),$(SUPPORTED_PYVERSIONS)),) +$(error Unsupported PYVERSION=$(PYVERSION). Must be one of $(SUPPORTED_PYVERSIONS)) endif +PYMINOR:=$(subst py3,,$(PYVERSION)) +PYNUMBER:=3$(PYMINOR) + # We actually do support glibc 2.28-2.30 (see #5636) # but those are old and there's no demand for it. ifeq ($(PLATFORM), linux64) From 3385872e8b04e4575cc63045a71c3154d1108ace Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 11:44:13 +0500 Subject: [PATCH 28/76] ci-black-formatting - use variables for min Python versions --- .github/workflows/ci-black-formatting.yaml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-black-formatting.yaml b/.github/workflows/ci-black-formatting.yaml index bbe9b37c26..f081f02945 100644 --- a/.github/workflows/ci-black-formatting.yaml +++ b/.github/workflows/ci-black-formatting.yaml @@ -7,6 +7,9 @@ on: jobs: lint-formatting: runs-on: ubuntu-latest + env: + MIN_IOS_PY_VERSION: "3.10" + MIN_BLENDER_PY_VERSION: "3.11" steps: - name: Action - checkout repository uses: actions/checkout@v6 @@ -14,12 +17,12 @@ jobs: - name: Action - install python uses: actions/setup-python@v6 with: - python-version: "3.10" + python-version: ${{ env.MIN_IOS_PY_VERSION }} - name: Action - install python uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version: ${{ env.MIN_BLENDER_PY_VERSION }} - name: Install dependencies run: | @@ -35,8 +38,8 @@ jobs: ERROR=0 # Using 2 Python versions - one minimum required for IfcOpenShell # and other that's used by Blender currently. - python3.10 -W error -m compileall -q src/ifcopenshell-python || ERROR=1 - python3.11 -W error -m compileall -q src/bonsai || ERROR=1 + python${{ env.MIN_IOS_PY_VERSION }} -W error -m compileall -q src/ifcopenshell-python || ERROR=1 + python${{ env.MIN_BLENDER_PY_VERSION }} -W error -m compileall -q src/bonsai || ERROR=1 exit $ERROR continue-on-error: true From 069dbbd8c2fbad08413e59a4e21fc17e8c7db194 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 11:48:15 +0500 Subject: [PATCH 29/76] bonsai docs - add maintenance page --- src/bonsai/docs/guides/development/index.rst | 1 + .../docs/guides/development/maintenance.rst | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 src/bonsai/docs/guides/development/maintenance.rst diff --git a/src/bonsai/docs/guides/development/index.rst b/src/bonsai/docs/guides/development/index.rst index 8852892361..d969791bf6 100644 --- a/src/bonsai/docs/guides/development/index.rst +++ b/src/bonsai/docs/guides/development/index.rst @@ -19,4 +19,5 @@ This chapter covers how you can help contribute to Bonsai. undo_system writing_docs debugging + maintenance ide/index diff --git a/src/bonsai/docs/guides/development/maintenance.rst b/src/bonsai/docs/guides/development/maintenance.rst new file mode 100644 index 0000000000..e1728fd7a5 --- /dev/null +++ b/src/bonsai/docs/guides/development/maintenance.rst @@ -0,0 +1,65 @@ +Maintenance +=========== + +This page documents what needs to be updated in various maintenance scenarios. + +Python Version Added or Removed +-------------------------------- + +When adding or removing a supported Python version, update the following: + +.. list-table:: + :header-rows: 1 + + * - File + - What to update + * - ``.github/workflows/ci-black-formatting.yaml`` + - ``MIN_IOS_PY_VERSION`` + * - ``.github/workflows/ci-ifcopenshell-python-pypi.yml`` + - ``pyver`` matrix + * - ``.github/workflows/ci-ifcopenshell-python.yml`` + - ``pyver`` matrix + * - ``nix/build-all.py`` + - ``PYTHON_VERSIONS`` list + * - ``src/bsdd/pyproject.toml`` + - ``requires-python`` + * - ``src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst`` + - add or remove the row in the ZIP packages table + * - ``src/ifcopenshell-python/Makefile`` + - ``SUPPORTED_PYVERSIONS`` + * - ``src/ifcopenshell-python/pyproject.toml`` + - ``requires-python`` + * - ``src/ifcopenshell-python/test/test_package.py`` + - ``SUPPORTED_PY_VERSIONS`` tuple + * - ``win/build-all-win.py`` + - ``PYTHON_VERSIONS`` list + +Blender Version Updated +----------------------- + +When a new Blender version is released and supported: + +.. list-table:: + :header-rows: 1 + + * - File + - What to update + * - ``.github/workflows/ci-bonsai-daily.yml`` + - Blender download URL + +Blender's Bundled Python Version Updated +----------------------------------------- + +When Blender ships with a new Python version: + +.. list-table:: + :header-rows: 1 + + * - File + - What to update + * - ``.github/workflows/ci-black-formatting.yaml`` + - ``MIN_BLENDER_PY_VERSION`` + * - ``src/bonsai/Makefile`` + - ``SUPPORTED_PYVERSIONS`` + * - ``src/bonsai/scripts/dev_environment.py`` + - ``PYTHON_VERSION`` mapping (Blender version, bundled Python version) From f2e2e324b1cd150305ae3e78ea86b8ff60cab9fe Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 12:16:28 +0500 Subject: [PATCH 30/76] Fixing stubs - `function_item`, `tags` added in df7318973 - MakeVolume added in c385b93, ignore as all other conversion settings - moved `SeparateZUpNode` ignore to the other geom serializer settings --- src/ifcopenshell-python/ifcopenshell/geom/main.py | 2 ++ src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi | 3 ++- src/ifcwrap/IfcPython.i | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index 299f09281a..36f9b48070 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -89,6 +89,7 @@ SETTING = Literal[ "keep-bounding-boxes", "layerset-first", "length-unit", + "make-volume", "max-offset-deviation", "max-offset", "mesher-angular-deflection", @@ -124,6 +125,7 @@ SERIALIZER_SETTING = Literal[ "ecef", "digits", "wkt-use-section", + "separate-z-up-node", ] # NOTE: hybrid-cgal-simple-opencascade is added just as an example diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index c906f57055..f2ae415ead 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -1143,7 +1143,8 @@ class loft: class loop: closed: Any external: Any - fi: Any + function_item: Any + tags: Any def calc_hash(self): ... def calculate_linear_edge_curves(self): ... def centroid(self): ... diff --git a/src/ifcwrap/IfcPython.i b/src/ifcwrap/IfcPython.i index db772ba26e..ca802cedfa 100644 --- a/src/ifcwrap/IfcPython.i +++ b/src/ifcwrap/IfcPython.i @@ -108,6 +108,7 @@ %ignore FloatingPointDigits; %ignore BaseUri; %ignore WktUseSection; +%ignore SeparateZUpNode; // ConversionSettings.h %ignore MesherLinearDeflection; %ignore MesherAngularDeflection; @@ -158,11 +159,11 @@ %ignore CgalEmitOriginalEdges; %ignore OcctNoCleanTriangulation; %ignore CacheShapes; +%ignore MakeVolume; %ignore DeferProcessingFirstElement; %ignore MaxOffset; %ignore MaxOffsetDeviation; %ignore ApplyOffset; -%ignore SeparateZUpNode; %ignore XmlSerializerFactory; %ignore JsonSerializerFactory; From 18c035ea77ba1860b38d914f88de6e68e2885eae Mon Sep 17 00:00:00 2001 From: tsomanna_QCOM Date: Wed, 18 Mar 2026 09:41:27 +0530 Subject: [PATCH 31/76] Fix Windows ARM64 Python Bindings Issue --- win/build-all-win.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/win/build-all-win.py b/win/build-all-win.py index 24c3e7dc8b..ded9a36bb6 100644 --- a/win/build-all-win.py +++ b/win/build-all-win.py @@ -114,7 +114,7 @@ def archive_python_packages() -> None: deps_path = REPO_PATH / "_deps" python_versions: list[str] = [] for d in deps_path.iterdir(): - if d.is_dir() and d.name.startswith("python."): + if d.is_dir() and (d.name.startswith("python.") or d.name.startswith("pythonarm64.")): python_version = d.name.partition(".")[2] python_path = d / "tools" archive_python_package(python_version, python_path) From 3b718bc58db8b14411476dc03d8292697f1877e7 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 12:32:26 +0500 Subject: [PATCH 32/76] Fix georeference core tests (b246998f6) --- src/bonsai/test/core/test_georeference.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/bonsai/test/core/test_georeference.py b/src/bonsai/test/core/test_georeference.py index b9b330d816..e95236ec3f 100644 --- a/src/bonsai/test/core/test_georeference.py +++ b/src/bonsai/test/core/test_georeference.py @@ -23,6 +23,7 @@ from test.core.bootstrap import georeference, ifc class TestAddGeoreferencing: def test_run(self, georeference): georeference.add_georeferencing().should_be_called() + georeference.set_model_origin().should_be_called() subject.add_georeferencing(georeference) @@ -35,9 +36,10 @@ class TestEnableEditingGeoreferencing: class TestRemoveGeoreferencing: - def test_run(self, ifc): + def test_run(self, ifc, georeference): ifc.run("georeference.remove_georeferencing").should_be_called() - subject.remove_georeferencing(ifc) + georeference.set_model_origin().should_be_called() + subject.remove_georeferencing(ifc, georeference) class TestDisableEditingGeoreferencing: From 58d07bace4b9fb56700b53f7ff922d28d43888df Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 12:42:09 +0500 Subject: [PATCH 33/76] Fix drawing edit_text core test and tool interface (5e9f97a0c) --- src/bonsai/bonsai/core/tool.py | 3 +++ src/bonsai/test/core/test_drawing.py | 11 ++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 340f9d7a64..d5383e2811 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -349,7 +349,10 @@ class Drawing: def enable_editing_text(cls, obj): pass def ensure_unique_drawing_name(cls, name): pass def ensure_unique_identification(cls, identification): pass + def export_font_size(cls, obj): pass + def export_symbol(cls, obj): pass def export_text_literal_attributes(cls, obj): pass + def export_wrap_length(cls, obj): pass def generate_drawing_matrix(cls, target_view, location_hint): pass def generate_drawing_name(cls, target_view, location_hint): pass def generate_reference_attributes(cls, reference, **attributes): pass diff --git a/src/bonsai/test/core/test_drawing.py b/src/bonsai/test/core/test_drawing.py index a83f026ad3..7fa724a173 100644 --- a/src/bonsai/test/core/test_drawing.py +++ b/src/bonsai/test/core/test_drawing.py @@ -35,9 +35,14 @@ class TestDisableEditingText: class TestEditText: def test_run(self, drawing): - drawing.synchronise_ifc_and_text_attributes("obj").should_be_called() - drawing.update_text_size_pset("obj").should_be_called() - drawing.update_text_annotation_properties("obj").should_be_called() + drawing.export_text_literal_attributes("obj").should_be_called().will_return("literal_attributes") + drawing.export_font_size("obj").should_be_called().will_return("font_size") + drawing.edit_text_font_size("obj", "font_size").should_be_called() + drawing.export_wrap_length("obj").should_be_called().will_return("wrap_length") + drawing.edit_text_wrap_length("obj", "wrap_length").should_be_called() + drawing.export_symbol("obj").should_be_called().will_return("symbol") + drawing.edit_text_symbol("obj", "symbol").should_be_called() + drawing.edit_text_literals("obj", "literal_attributes").should_be_called() drawing.disable_editing_text("obj").should_be_called() subject.edit_text(drawing, obj="obj") From 64003fd5ef7b7b4dc1caa4ac888a5f70cf5a5206 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 12:49:07 +0500 Subject: [PATCH 34/76] Fix drawing update_drawing_name core test (19534e225) --- src/bonsai/bonsai/core/drawing.py | 5 +---- src/bonsai/bonsai/core/tool.py | 1 + src/bonsai/bonsai/tool/drawing.py | 6 ++++++ src/bonsai/test/core/test_drawing.py | 2 ++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 42b0f77fbb..5db2ced03e 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -432,10 +432,7 @@ def update_drawing_name( if drawing_tool.get_name(drawing) != name: ifc.run("attribute.edit_attributes", product=drawing, attributes={"Name": name}) - # Update the camera object name - camera = ifc.get_object(drawing) - if camera and camera.name != name: - camera.name = name + drawing_tool.set_camera_name(drawing, name) group = drawing_tool.get_drawing_group(drawing) if drawing_tool.get_name(group) != name: diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index d5383e2811..239e7308ca 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -405,6 +405,7 @@ class Drawing: def run_root_assign_class(cls, obj=None, ifc_class=None, predefined_type=None, should_add_representation=True, context=None, ifc_representation_class=None): pass def run_type_assign_type(cls, element=None, relating_type=None): pass def select_assigned_product(cls, drawing): pass + def set_camera_name(cls, drawing, name): pass def set_drawing_collection_name(cls, drawing, collection): pass def set_name(cls, element, name): pass def setup_annotation_object(cls, obj, object_type): pass diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index a78fd67ed2..accaaad10d 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -1288,6 +1288,12 @@ class Drawing(bonsai.core.tool.Drawing): def get_representation(cls, element, context): return ifcopenshell.util.representation.get_representation(element, context) + @classmethod + def set_camera_name(cls, drawing: ifcopenshell.entity_instance, name: str) -> None: + camera = tool.Ifc.get_object(drawing) + if camera and camera.name != name: + camera.name = name + @classmethod def set_drawing_collection_name( cls, drawing: ifcopenshell.entity_instance, collection: bpy.types.Collection diff --git a/src/bonsai/test/core/test_drawing.py b/src/bonsai/test/core/test_drawing.py index 7fa724a173..52822aa754 100644 --- a/src/bonsai/test/core/test_drawing.py +++ b/src/bonsai/test/core/test_drawing.py @@ -471,6 +471,7 @@ class TestRemoveDrawing: class TestUpdateDrawingName: def test_do_not_update_if_name_unchanged(self, ifc, drawing): drawing.get_name("drawing").should_be_called().will_return("name") + drawing.set_camera_name("drawing", "name").should_be_called() drawing.get_drawing_group("drawing").should_be_called().will_return("group") drawing.get_name("group").should_be_called().will_return("name") drawing.get_drawing_collection("drawing").should_be_called().will_return("collection") @@ -487,6 +488,7 @@ class TestUpdateDrawingName: def test_run(self, ifc, drawing): drawing.get_name("drawing").should_be_called().will_return("oldname") ifc.run("attribute.edit_attributes", product="drawing", attributes={"Name": "name"}).should_be_called() + drawing.set_camera_name("drawing", "name").should_be_called() drawing.get_drawing_group("drawing").should_be_called().will_return("group") drawing.get_name("group").should_be_called().will_return("oldname") ifc.run("attribute.edit_attributes", product="group", attributes={"Name": "name"}).should_be_called() From ec6c268cdb44a0fc365c44f1c8ad754458868e57 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 13:14:49 +0500 Subject: [PATCH 35/76] Fix type assign_type core test (44a52863a) --- src/bonsai/bonsai/core/tool.py | 5 +++++ src/bonsai/test/core/test_type.py | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 239e7308ca..09a4fe90f0 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -624,7 +624,10 @@ class Model: def import_rectangle(cls, obj, position, profile): pass def load_openings(cls, openings): pass def purge_scene_openings(cls): pass + def recalculate_walls(cls, objs): pass def regenerate_array(cls, parent, data): pass + def regenerate_profile(cls, obj): pass + def regenerate_slab(cls, obj): pass def reload_body_representation(cls, obj_or_objects): pass def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass @@ -1123,6 +1126,8 @@ class Type: def get_representation_context(cls, representation): pass def get_type_occurrences(cls, element_type): pass def has_material_usage(cls, element): pass + def record_material_usage_attributes(cls, element): pass + def restore_material_usage_attributes(cls, element, usage_attributes): pass def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass def run_geometry_switch_representation(cls, obj=None, representation=None): pass diff --git a/src/bonsai/test/core/test_type.py b/src/bonsai/test/core/test_type.py index e46cf8168f..031e414f95 100644 --- a/src/bonsai/test/core/test_type.py +++ b/src/bonsai/test/core/test_type.py @@ -22,8 +22,9 @@ from test.core.bootstrap import geometry, ifc, model, type class TestAssignType: def test_assigning_and_switching_to_an_existing_type_data(self, ifc, model, type): + type.record_material_usage_attributes("element").should_be_called().will_return(None) ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called() - type.has_material_usage("element").should_be_called().will_return(False) + model.get_usage_type("type").should_be_called(2).will_return(None) ifc.get_object("type").should_be_called().will_return("type_obj") type.get_object_data("type_obj").should_be_called().will_return("type_obj_data") type.change_object_data("obj", "type_obj_data", is_global=False).should_be_called() @@ -31,9 +32,10 @@ class TestAssignType: type.disable_editing("obj").should_be_called() subject.assign_type(ifc, model, type, element="element", type="type") - def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, type): + def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, model, type): + type.record_material_usage_attributes("element").should_be_called().will_return(None) ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called() - type.has_material_usage("element").should_be_called().will_return(False) + model.get_usage_type("type").should_be_called(2).will_return(None) ifc.get_object("type").should_be_called().will_return("type_obj") type.get_object_data("type_obj").should_be_called().will_return(None) ifc.get_object("element").should_be_called().will_return("obj") From fd902d88fb43a0ff7a52def890ad9cad5cdcf642 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 18 Mar 2026 18:25:03 -0500 Subject: [PATCH 36/76] Update selector_syntax.rst with query examples Clarified usage of queries in IfcAnnotation tags with examples. --- .../docs/ifcopenshell-python/selector_syntax.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst index 4e28e83ed4..5ae16c5ec0 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst @@ -257,7 +257,8 @@ nest formulas, for example ``concat(title("foo"), lower("Bar"))`` will produce "``join({{separator}}, {{values}})``", "``join(""-"", {{mats.Name}})``", "``Name1-Name2``", "Joins a list of items with a custom separator. By default, all lists a rendered as comma separated." "``{{value1}}[+-*/]{{value2}}``", "``{{z}}+3``", "``5``", "Does arithmetic. Typical operators such as +, -, \*, and / are allowed and can be mixed with other variables and formatting functions." -When using queries in an IfcAnnotation tag surround with backticks. -Examples: -````number({{Qto_WallBaseQuantities.Width}}, ",",".")```` or -````round({{Qto_BuildingElementProxyQuantities.NetVolume}},.1)```` +When using queries in an IfcAnnotation tag surround with backticks. Examples: + +- ````number({{Qto_WallBaseQuantities.Width}}, ",",".")```` +- ````round({{Qto_BuildingElementProxyQuantities.NetVolume}},.1)```` +- ````join(", OVER ", reverse({{material.item.Material.Name}}))```` From 3590b08e68d1638b15c3b7acf52ef5053fd357d4 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 09:48:49 +0500 Subject: [PATCH 37/76] search/operator - remove unnecessary Ifc Operators --- src/bonsai/bonsai/bim/module/search/operator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index 38097cb974..d5a6b9b1e6 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -619,7 +619,7 @@ class SelectFilterElements(bpy.types.Operator): return {"FINISHED"} -class ApplyFilterFromText(Operator, tool.Ifc.Operator): +class ApplyFilterFromText(Operator): bl_idname = "bim.apply_filter_from_text" bl_label = "Apply Filter Configuration" bl_description = "Apply the JSON filter configuration from the current text block" @@ -1440,7 +1440,7 @@ class ShowAllElements(Operator): return {"FINISHED"} -class SelectSimilar(Operator, tool.Ifc.Operator): +class SelectSimilar(Operator): bl_idname = "bim.select_similar" bl_label = "Select Similar" bl_options = {"REGISTER", "UNDO"} From 3bf0edeca2c071389b0695d52b72de3af9734c90 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 19:28:05 +0500 Subject: [PATCH 38/76] Fix subtle walrus operator bug in align_walls using e before assignment --- src/bonsai/bonsai/core/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index 7b17d5de5d..eb76202fd3 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -109,7 +109,7 @@ def align_walls( align_type: AlignType, ): reference_obj = blender.get_active_object(is_selected=True) - if not (e := ifc.get_entity(reference_obj) or not model.get_usage_type(e) == "LAYER2"): + if not reference_obj or not (e := ifc.get_entity(reference_obj)) or not model.get_usage_type(e) == "LAYER2": reference_obj = None objs = [ o From 30551cb288b532e52bb927ca86274580eccd5fe0 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 18 Mar 2026 19:34:56 +0500 Subject: [PATCH 39/76] typing --- src/bcf/bcf/v3/bcfapi.py | 6 +-- .../bonsai/bim/module/drawing/gizmos.py | 2 +- .../bonsai/bim/module/georeference/prop.py | 8 ++- src/bonsai/bonsai/bim/module/model/profile.py | 54 +++++++++++++------ .../module/structural/load_decoration_data.py | 3 +- src/bonsai/bonsai/tool/bsdd.py | 6 ++- .../ifcopenshell/__init__.py | 6 ++- 7 files changed, 58 insertions(+), 27 deletions(-) diff --git a/src/bcf/bcf/v3/bcfapi.py b/src/bcf/bcf/v3/bcfapi.py index 3ac85b9685..368c294cd0 100644 --- a/src/bcf/bcf/v3/bcfapi.py +++ b/src/bcf/bcf/v3/bcfapi.py @@ -34,8 +34,8 @@ client_id, client_secret = "", "" class OAuthReceiver(http.server.BaseHTTPRequestHandler): def do_GET(self) -> None: query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) - self.server.auth_code = query.get("code", [""])[0] # type: ignore - self.server.auth_state = query.get("state", [""])[0] # type: ignore + self.server.auth_code = query.get("code", [""])[0] + self.server.auth_state = query.get("state", [""])[0] self.send_response(200) self.send_header("Content-type", "text/plain") self.end_headers() @@ -255,7 +255,7 @@ class BcfClient: project_id: str = "", topics: str = "", query_string: Optional[str] = None, - ) -> list[Any]: + ) -> None: # return self.get( # f"/projects/{project_id}/topics", # { diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 7f3a53ddf4..d350cf80ee 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -1285,7 +1285,7 @@ class SnapManager: continue coords = np.empty(vertex_count * 3, dtype=np.float32) - mesh.vertices.foreach_get("co", coords) # type: ignore[arg-type] + mesh.vertices.foreach_get("co", coords) coords = coords.reshape(-1, 3) matrix = np.array(obj_eval.matrix_world, dtype=np.float32) diff --git a/src/bonsai/bonsai/bim/module/georeference/prop.py b/src/bonsai/bonsai/bim/module/georeference/prop.py index da92edf4e6..9b1f8d5f4d 100644 --- a/src/bonsai/bonsai/bim/module/georeference/prop.py +++ b/src/bonsai/bonsai/bim/module/georeference/prop.py @@ -139,7 +139,9 @@ def update_local_coordinates(self: "BIMGeoreferenceProperties", context: bpy.typ tool.Georeference.set_coordinates( "blender", ifcopenshell.util.geolocation.enh2xyz( - *local_coordinates, + local_coordinates[0], + local_coordinates[1], + local_coordinates[2], float(props.blender_offset_x), float(props.blender_offset_y), float(props.blender_offset_z), @@ -162,7 +164,9 @@ def update_map_coordinates(self: "BIMGeoreferenceProperties", context: bpy.types tool.Georeference.set_coordinates( "blender", ifcopenshell.util.geolocation.enh2xyz( - *local_coordinates, + local_coordinates[0], + local_coordinates[1], + local_coordinates[2], float(props.blender_offset_x), float(props.blender_offset_y), float(props.blender_offset_z), diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index f759d40a94..63c3f8dfa4 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -18,7 +18,7 @@ import copy from math import atan2, degrees, pi, radians -from typing import Any, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, Union import bpy import ifcopenshell @@ -49,7 +49,7 @@ ProfileFrom2PointsReturn = Union[dict[str, Any], None] class DumbProfileGenerator: - def __init__(self, relating_type): + def __init__(self, relating_type: ifcopenshell.entity_instance): self.relating_type = relating_type self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) @@ -201,7 +201,7 @@ class DumbProfileGenerator: class DumbProfileRegenerator: - def regenerate_from_profile_def(self, profile): + def regenerate_from_profile_def(self, profile: ifcopenshell.entity_instance) -> None: self.file = tool.Ifc.get() objs = [] if not profile: @@ -221,7 +221,7 @@ class DumbProfileRegenerator: for element in self.get_element_types_using_profile(profile): tool.Model.mark_thumbnail_for_update(element) - def regenerate_from_profile(self, usecase_path, ifc_file, settings): + def regenerate_from_profile(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None: self.file = ifc_file objs = [] profile = settings["profile"].Profile @@ -233,7 +233,7 @@ class DumbProfileRegenerator: objs.append(obj) DumbProfileRecalculator().recalculate(objs) - def get_elements_using_profile(self, profile): + def get_elements_using_profile(self, profile: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: results = [] profile_sets = [ mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile") @@ -252,7 +252,7 @@ class DumbProfileRegenerator: results.extend(rel.RelatedObjects) return results - def get_element_types_using_profile(self, profile): + def get_element_types_using_profile(self, profile: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: results = [] profile_sets = [ mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile") @@ -269,12 +269,18 @@ class ExtendProfile(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.extend_profile" bl_label = "Extend Profile" bl_options = {"REGISTER", "UNDO"} - join_type: bpy.props.StringProperty() + join_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")], + default="-", + ) + + if TYPE_CHECKING: + join_type: Literal["-", "L", "V", "T"] def _execute(self, context): selected_objs = context.selected_objects joiner = DumbProfileJoiner() - if not self.join_type: + if self.join_type == "-": for obj in selected_objs: joiner.unjoin(obj) return {"FINISHED"} @@ -626,11 +632,15 @@ class DumbProfileJoiner: if connection1 == "ATEND": if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal: plane = self.get_profile_plane(profile2, furthest_plane) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) self.body[1] = intersect else: plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) max_dim = self.get_max_bound_box_dimension(profile1) self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim)) @@ -673,11 +683,15 @@ class DumbProfileJoiner: elif connection1 == "ATSTART": if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal: plane = self.get_profile_plane(profile2, furthest_plane) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) self.body[0] = intersect else: plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) max_dim = self.get_max_bound_box_dimension(profile1) self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim)) @@ -721,7 +735,9 @@ class DumbProfileJoiner: if connection1 == "ATEND": if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal: plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) self.body[1] = intersect else: plane = self.get_profile_plane( @@ -729,7 +745,9 @@ class DumbProfileJoiner: furthest_plane if is_relating else closest_plane, z_inwards=False if is_relating else True, ) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) max_dim = self.get_max_bound_box_dimension(profile1) self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim)) self.clippings.append( @@ -742,7 +760,9 @@ class DumbProfileJoiner: elif connection1 == "ATSTART": if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal: plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) self.body[0] = intersect else: plane = self.get_profile_plane( @@ -750,7 +770,9 @@ class DumbProfileJoiner: furthest_plane if is_relating else closest_plane, z_inwards=False if is_relating else True, ) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) max_dim = self.get_max_bound_box_dimension(profile1) self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim)) self.clippings.append( diff --git a/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py b/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py index 132c305ad1..dc05fab89c 100644 --- a/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py +++ b/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py @@ -28,6 +28,7 @@ import ifcopenshell.util.representation import ifcopenshell.util.unit import ifcopenshell.util.unit as ifcunit import numpy as np +import numpy.typing as npt from mathutils import Vector import bonsai.tool as tool @@ -478,7 +479,7 @@ class ShaderInfo: """get the args to the point shader""" location = np.array(location) indices = [] - direction_dict = { + direction_dict: dict[str, tuple[npt.NDArray, ...]] = { "fx": (np.array((1, 0, 0)), np.array((0, 1, 0)), np.array((0, 0, 1))), "fy": (np.array((0, 1, 0)), np.array((1, 0, 0)), np.array((0, 0, 1))), "fz": (np.array((0, 0, 1)), np.array((0, 1, 0)), np.array((1, 0, 0))), diff --git a/src/bonsai/bonsai/tool/bsdd.py b/src/bonsai/bonsai/tool/bsdd.py index 74d9926f15..2d9c5605bf 100644 --- a/src/bonsai/bonsai/tool/bsdd.py +++ b/src/bonsai/bonsai/tool/bsdd.py @@ -32,6 +32,8 @@ import bonsai.core.tool import bonsai.tool as tool if TYPE_CHECKING: + from bsdd.bsdd import ClassContractV1, ClassPropertyContractV1, PropertyContractV5 + from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDDictionary @@ -39,8 +41,8 @@ class Bsdd(bonsai.core.tool.Bsdd): default_identifier_url = "https://identifier.buildingsmart.org" default_api_url = "https://api.bsdd.buildingsmart.org/api/" client = bsdd.Client() - bsdd_classes: dict[str, dict] = {} - bsdd_properties: dict[str, dict] = {} + bsdd_classes: dict[str, ClassContractV1] = {} + bsdd_properties: dict[str, ClassPropertyContractV1 | PropertyContractV5] = {} @classmethod def identifier_url(cls) -> str: diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index 998eb6e5de..28a3512e2e 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -199,11 +199,13 @@ def open( for ty in bypass_types: f.bypass_type(ty) if mmap: - f.initialize(str(path.absolute()), mmap=mmap) + # mmap parameter is only available for builds with USE_MMAP, not used in our main builds + f.initialize(str(path.absolute()), mmap=mmap) # type: ignore[unknown-argument] else: f.initialize(str(path.absolute())) elif mmap: - f = ifcopenshell_wrapper.open(str(path.absolute()), mmap=mmap) + # mmap parameter is only available for builds with USE_MMAP, not used in our main builds + f = ifcopenshell_wrapper.open(str(path.absolute()), mmap=mmap) # type: ignore[unknown-argument] else: f = ifcopenshell_wrapper.open(str(path.absolute())) return file(f) From 26280d24fed721d1c9c3253ba168ee498fe8429a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 15:35:53 +0500 Subject: [PATCH 40/76] Add `ty` to check for missing symbols and other simple errors --- pyproject.toml | 167 +++++++++++++++++- .../bonsai/bim/module/aggregate/decorator.py | 2 +- src/bonsai/bonsai/bim/module/bcf/prop.py | 2 +- src/bonsai/bonsai/bim/module/brick/prop.py | 8 +- .../bonsai/bim/module/drawing/helper.py | 3 +- .../bonsai/bim/module/geometry/operator.py | 2 +- src/bonsai/bonsai/bim/module/gis/prop.py | 2 +- src/bonsai/bonsai/bim/module/light/prop.py | 2 +- src/bonsai/bonsai/bim/module/model/product.py | 8 +- src/bonsai/bonsai/bim/module/model/task.py | 4 +- .../bonsai/bim/module/nest/decorator.py | 2 +- .../bonsai/bim/module/qto/calculator.py | 2 +- .../bonsai/bim/module/structural/shader.py | 6 +- src/bonsai/bonsai/tool/cost.py | 2 +- src/bonsai/bonsai/tool/ifcgit.py | 2 +- src/bonsai/scripts/bonsai_deps.py | 23 +++ src/bonsai/scripts/bonsai_translations.py | 4 +- src/bonsai/scripts/gbxml.py | 4 +- .../generate_steel_profiles_library.py | 2 +- src/bonsai/scripts/obj2ifc-meshlab.py | 2 +- src/bonsai/scripts/obj2ifc.py | 2 +- src/bonsai/test/bim/test_feature.py | 2 +- src/bonsai/type-check-requirements.txt | 41 +++++ src/bsdd/bsdd_json.py | 2 +- .../ifcopenshell/__init__.py | 4 +- .../api/geometry/add_representation.py | 6 +- .../ifcopenshell/api/project/append_asset.py | 2 +- .../api/style/add_surface_textures.py | 2 +- .../ifcopenshell/geom/__init__.py | 4 +- .../ifcopenshell/geom/main.py | 6 +- .../ifcopenshell/geom/occ_utils.py | 24 ++- .../ifcopenshell/util/shape_builder.py | 2 +- .../ifcopenshell/validate.py | 3 +- .../test/util/test_shape_builder.py | 10 +- .../type-check-requirements.txt | 31 ++++ .../recipes/FixArchiCADToRevitSpaces.py | 4 +- .../ifcpatch/recipes/FixRevit2025TINs.py | 16 +- src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py | 4 +- .../ifcpatch/recipes/MergeDuplicateTypes.py | 2 +- src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py | 2 +- src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py | 2 +- 41 files changed, 350 insertions(+), 70 deletions(-) create mode 100644 src/bonsai/scripts/bonsai_deps.py create mode 100644 src/bonsai/type-check-requirements.txt create mode 100644 src/ifcopenshell-python/type-check-requirements.txt diff --git a/pyproject.toml b/pyproject.toml index 895e74679e..a306ffa166 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,14 +79,136 @@ ignore = [ ] [tool.ty.rules] -# We only use `ty` currently to check for deprecated methods. all = "ignore" + +# Structural rules (no deep type inference needed, easier to adapt). +abstract-method-in-final-class = "error" +ambiguous-protocol-member = "error" +byte-string-type-annotation = "error" +conflicting-declarations = "error" +conflicting-metaclass = "error" +cyclic-class-definition = "error" +cyclic-type-alias-definition = "error" +dataclass-field-order = "error" +duplicate-base = "error" +duplicate-kw-only = "error" +empty-body = "error" +escape-character-in-forward-annotation = "error" +final-on-non-method = "error" +final-without-value = "error" +fstring-type-annotation = "error" +ignore-comment-unknown-rule = "error" +implicit-concatenated-string-type-annotation = "error" +inconsistent-mro = "error" +ineffective-final = "error" +instance-layout-conflict = "error" +invalid-dataclass = "error" +invalid-dataclass-override = "error" +invalid-enum-member-annotation = "error" +invalid-explicit-override = "error" +invalid-frozen-dataclass-subclass = "error" +invalid-generic-class = "error" +invalid-generic-enum = "error" +invalid-ignore-comment = "error" +invalid-legacy-positional-parameter = "error" +invalid-legacy-type-variable = "error" +invalid-named-tuple = "error" +invalid-newtype = "error" +invalid-overload = "error" +invalid-paramspec = "error" +invalid-protocol = "error" +invalid-syntax-in-forward-annotation = "error" +invalid-total-ordering = "error" +invalid-type-alias-type = "error" +invalid-type-checking-constant = "error" +invalid-type-guard-definition = "error" +invalid-type-variable-bound = "error" +invalid-type-variable-constraints = "error" +invalid-typed-dict-header = "error" +invalid-typed-dict-statement = "error" +override-of-final-method = "error" +override-of-final-variable = "error" +possibly-missing-import = "error" +possibly-missing-submodule = "error" +# Has false positives due to ty walrus operator bug. +# possibly-unresolved-reference = "error" +raw-string-type-annotation = "error" +redundant-final-classvar = "error" +shadowed-type-variable = "error" +subclass-of-final-class = "error" +super-call-in-named-tuple-method = "error" +unavailable-implicit-super-arguments = "error" +unbound-type-variable = "error" +undefined-reveal = "error" +unresolved-global = "error" +unresolved-import = "error" +unresolved-reference = "error" +unused-ignore-comment = "error" +unused-type-ignore-comment = "error" +useless-overload-body = "error" + +# Non-structural rules: deprecated = "error" +zero-stepsize-in-slice = "error" +possibly-missing-implicit-call = "error" +unused-awaitable = "error" + +# Function argument rules: +# Conflicts with `ifcopenshell.api.geometry.add_representation` type of callables we have, confusing them with a module. +# call-non-callable = "error" +conflicting-argument-forms = "error" +# Too many false positives. +# invalid-argument-type = "error" +missing-argument = "error" +parameter-already-assigned = "error" +positional-only-parameter-as-kwarg = "error" +too-many-positional-arguments = "error" +unknown-argument = "error" +# Has a lot of warnings due to current ty walrus operator issues. +# index-out-of-bounds = "error" +# unresolved-attribute = "error" + +[tool.ty.environment] +extra-paths = [ + "src/bonsai/external_dependencies", + "src/bcf", + "src/bsdd", + "src/bonsai", + "src/ifc4d", + "src/ifc5d", + "src/ifccityjson", + "src/ifcclash", + "src/ifccsv", + "src/ifcdiff", + "src/ifcfm", + "src/ifcopenshell-python", + "src/ifcpatch", + "src/ifctester", +] [tool.ty.src] exclude = [ - "src/ifc2ca/templates", + # External dependencies cloned for type checking only. + "src/bonsai/external_dependencies", + # Submodules. + "src/ifcopenshell-python/ifcopenshell/express", + "src/ifcopenshell-python/ifcopenshell/mvd", + "src/ifcopenshell-python/ifcopenshell/simple_spf", "src/svgfill/3rdparty", + # Has special dependencies. + "src/ifcopenshell-python/ifcopenshell/geom/app.py", + "src/ifcopenshell-python/ifcopenshell/geom/code_editor_pane.py", + "src/ifcopenshell-python/ifcopenshell/util/doc.py", + "src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py", + "src/ifcopenshell-python/ifcopenshell/util/ifc4x3dev_scrape_data_for_docs.py", + # Too esoteric. + "src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.py", + "src/ifc2ca/templates", + # Too dev. + "src/bcf/setup.py", + "src/bsdd/yml_to_classes.py", + # Deprecated. + "src/ifc2ca/_deprecated", ] [tool.poe.tasks] @@ -98,8 +220,47 @@ ruff.sequence = ["ruff-main", "ruff-old"] black = "black ." -ty = "ty check" +ty.sequence = ["ty-bonsai", "ty-ios"] +ty.help = "Run ty type checker. Requires ty-venv to be set up first." +ty-bonsai = "ty check src/bonsai --python=src/bonsai/.venv" + +ty-venv.sequence = ["ty-venv-bonsai", "ty-venv-ios"] + +ty-venv-bonsai.sequence = [ + {cmd = "uv venv src/bonsai/.venv --python=3.11 --allow-existing"}, + {cmd = "uv pip install -r src/bonsai/type-check-requirements.txt --python=src/bonsai/.venv"}, +] + +ty-venv-ios.sequence = [ + {cmd = "uv venv src/ifcopenshell-python/.venv --python=3.10 --allow-existing"}, + {cmd = "uv pip install -r src/ifcopenshell-python/type-check-requirements.txt --python=src/ifcopenshell-python/.venv"}, +] format.sequence = ["black", "ruff-main", "ruff-old"] cmake-format = "gersemi . --in-place" + +[tool.poe.tasks.ty-ios] +# --ignore unresolved-reference: walrus operator false positives in ty. +cmd = """ + ty check + src/bcf + src/bsdd + src/ifc2ca + src/ifc4d + src/ifc5d + src/ifccityjson + src/ifcclash + src/ifccsv + src/ifcdiff + src/ifcfm + src/ifcopenshell-python + src/ifcpatch + src/ifctester + --python=src/ifcopenshell-python/.venv + --ignore unresolved-reference +""" + +[tool.poe.tasks.bonsai-deps] +help = "Clone or update Bonsai external dependencies." +cmd = "python src/bonsai/scripts/bonsai_deps.py" diff --git a/src/bonsai/bonsai/bim/module/aggregate/decorator.py b/src/bonsai/bonsai/bim/module/aggregate/decorator.py index eb389a58bd..2cd1c1bca0 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/decorator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/decorator.py @@ -101,7 +101,7 @@ class AggregateDecorator: cls.is_installed = False def dotted_line_shader(self): - vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments] vert_out.smooth("FLOAT", "v_ArcLength") shader_info = gpu.types.GPUShaderCreateInfo() diff --git a/src/bonsai/bonsai/bim/module/bcf/prop.py b/src/bonsai/bonsai/bim/module/bcf/prop.py index 3ac751387e..651c0c052f 100644 --- a/src/bonsai/bonsai/bim/module/bcf/prop.py +++ b/src/bonsai/bonsai/bim/module/bcf/prop.py @@ -230,7 +230,7 @@ class BcfTopic(PropertyGroup): def get_related_topics(self: "BCFProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: - global RELATED_TOPICS_ENUM_ITEMS + global RELATED_TOPICS_ENUM_ITEMS # ty: ignore[unresolved-global] props = self active_topic = props.active_topic active_related_topics = active_topic.related_topics.keys() diff --git a/src/bonsai/bonsai/bim/module/brick/prop.py b/src/bonsai/bonsai/bim/module/brick/prop.py index 6201113a09..2d507411e4 100644 --- a/src/bonsai/bonsai/bim/module/brick/prop.py +++ b/src/bonsai/bonsai/bim/module/brick/prop.py @@ -46,26 +46,26 @@ def get_libraries(self, context): def get_namespaces(self, context): - global NAMESPACES_ENUM_ITEMS + global NAMESPACES_ENUM_ITEMS # ty: ignore[unresolved-global] NAMESPACES_ENUM_ITEMS = [(uri, f"{alias}: {uri}", "") for alias, uri in BrickStore.namespaces] return NAMESPACES_ENUM_ITEMS def get_brick_entity_classes(self, context): - global ENTITY_CLASSES_ENUM_ITEMS + global ENTITY_CLASSES_ENUM_ITEMS # ty: ignore[unresolved-global] entity = self.brick_entity_create_type ENTITY_CLASSES_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.entity_classes[entity]] return ENTITY_CLASSES_ENUM_ITEMS def get_brick_roots(self, context): - global BRICK_ROOTS_ENUM_ITEMS + global BRICK_ROOTS_ENUM_ITEMS # ty: ignore[unresolved-global] BRICK_ROOTS_ENUM_ITEMS = [(root, root, "") for root in BrickStore.root_classes] return BRICK_ROOTS_ENUM_ITEMS def get_brick_relations(self, context): - global BRICK_RELATIONS_ENUM_ITEMS + global BRICK_RELATIONS_ENUM_ITEMS # ty: ignore[unresolved-global] BRICK_RELATIONS_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.relationships] for relation in BrickschemaData.data["active_relations"]: if relation["predicate_name"] == "label": diff --git a/src/bonsai/bonsai/bim/module/drawing/helper.py b/src/bonsai/bonsai/bim/module/drawing/helper.py index 7dce81359d..d4895410cb 100644 --- a/src/bonsai/bonsai/bim/module/drawing/helper.py +++ b/src/bonsai/bonsai/bim/module/drawing/helper.py @@ -456,7 +456,8 @@ def format_distance( tx_dist = fmt % d_cm else: - tx_dist = fmt % value + assert f"Unexpected unit_system - '{unit_system}'." + # tx_dist = fmt % value return tx_dist diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index f8389cb3af..d5f159abe3 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -1066,7 +1066,7 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator): cls.poll_message_set("Only available from Outliner.") return False - def execute(self, context): + def execute(self, context): # ty:ignore[override-of-final-method] if len(getattr(context, "selected_ids", [])) == 0: return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/gis/prop.py b/src/bonsai/bonsai/bim/module/gis/prop.py index 9f685517c3..5971fa80a9 100644 --- a/src/bonsai/bonsai/bim/module/gis/prop.py +++ b/src/bonsai/bonsai/bim/module/gis/prop.py @@ -27,7 +27,7 @@ from bonsai.bim.prop import StrProperty class BIMCityJsonProperties(PropertyGroup): def get_lods(self, context): - global LODS_ENUM_ITEMS + global LODS_ENUM_ITEMS # ty: ignore[unresolved-global] LODS_ENUM_ITEMS = [(item.name, "LOD" + item.name, "Level of Detail " + item.name) for item in self.lods] return LODS_ENUM_ITEMS diff --git a/src/bonsai/bonsai/bim/module/light/prop.py b/src/bonsai/bonsai/bim/module/light/prop.py index d3498f18b8..24233b44d8 100644 --- a/src/bonsai/bonsai/bim/module/light/prop.py +++ b/src/bonsai/bonsai/bim/module/light/prop.py @@ -320,7 +320,7 @@ class RadianceExporterProperties(PropertyGroup): ) def get_subcategories(self, context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS: - global SUBCATEGORIES_ENUM_ITEMS + global SUBCATEGORIES_ENUM_ITEMS # ty: ignore[unresolved-global] if self.category in spectraldb: SUBCATEGORIES_ENUM_ITEMS = [(k, k, "") for k in spectraldb[self.category].keys()] else: diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index d7c96bce1d..4cf4e00172 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -694,10 +694,14 @@ def generate_box(usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[ new_settings = settings.copy() new_settings["context"] = box_context - new_box = ifcopenshell.api.geometry.add_representation(ifc_file, should_run_listeners=False, **new_settings) + new_box = ifcopenshell.api.geometry.add_representation( + ifc_file, + should_run_listeners=False, # ty:ignore[unknown-argument] + **new_settings, + ) ifcopenshell.api.geometry.assign_representation( ifc_file, - should_run_listeners=False, + should_run_listeners=False, # ty:ignore[unknown-argument] product=product, representation=new_box, ) diff --git a/src/bonsai/bonsai/bim/module/model/task.py b/src/bonsai/bonsai/bim/module/model/task.py index a6fe2607a2..72d2ee4556 100644 --- a/src/bonsai/bonsai/bim/module/model/task.py +++ b/src/bonsai/bonsai/bim/module/model/task.py @@ -31,11 +31,11 @@ def calculate_quantities(usecase_path, ifc_file: ifcopenshell.file, settings): return task = next(e for e in ifc_file.get_inverse(element) if e.is_a("IfcTask")) qto = ifcopenshell.api.pset.add_qto( - ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities" + ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities" # ty:ignore[unknown-argument] ) ifcopenshell.api.pset.edit_qto( ifc_file, - should_run_listeners=False, + should_run_listeners=False, # ty:ignore[unknown-argument] qto=qto, properties={ "StandardWork": ifcopenshell.util.date.ifc2datetime(element.ScheduleDuration).days, diff --git a/src/bonsai/bonsai/bim/module/nest/decorator.py b/src/bonsai/bonsai/bim/module/nest/decorator.py index 28c3835ba7..4a3637caa6 100644 --- a/src/bonsai/bonsai/bim/module/nest/decorator.py +++ b/src/bonsai/bonsai/bim/module/nest/decorator.py @@ -101,7 +101,7 @@ class NestDecorator: cls.is_installed = False def dotted_line_shader(self): - vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments] vert_out.smooth("FLOAT", "v_ArcLength") shader_info = gpu.types.GPUShaderCreateInfo() diff --git a/src/bonsai/bonsai/bim/module/qto/calculator.py b/src/bonsai/bonsai/bim/module/qto/calculator.py index c0e5aa474f..638d603f89 100644 --- a/src/bonsai/bonsai/bim/module/qto/calculator.py +++ b/src/bonsai/bonsai/bim/module/qto/calculator.py @@ -321,7 +321,7 @@ def get_gross_perimeter(o: bpy.types.Object) -> float: return gross_perimeter -def get_space_net_perimeter(obj: bpy.types.Object) -> float: +def get_space_net_perimeter(obj: bpy.types.Object) -> None: pass diff --git a/src/bonsai/bonsai/bim/module/structural/shader.py b/src/bonsai/bonsai/bim/module/structural/shader.py index b9b5a5c7bc..9688ce9f0e 100644 --- a/src/bonsai/bonsai/bim/module/structural/shader.py +++ b/src/bonsai/bonsai/bim/module/structural/shader.py @@ -83,7 +83,7 @@ class DecorationShader: PARALLEL DISTRIBUTED FORCE, DISTRIBUTED MOMENT, """ - vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments] vert_out.smooth("VEC3", "forces") vert_out.smooth("VEC3", "co") @@ -203,7 +203,7 @@ class DecorationShader: """param: pattern: type of pattern SINGLE FORCE, SINGLE MOMENT""" - vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments] vert_out.smooth("VEC3", "co") shader_info = gpu.types.GPUShaderCreateInfo() @@ -253,7 +253,7 @@ class DecorationShader: def get_planar_shader(self) -> gpu.types.GPUShader: """shader for planar loads""" - vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments] vert_out.smooth("VEC3", "co") shader_info = gpu.types.GPUShaderCreateInfo() diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index 663b16040e..bbec525ee9 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -154,7 +154,7 @@ class Cost(bonsai.core.tool.Cost): 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) + sound = aud.Sound(filepath) # ty:ignore[too-many-positional-arguments] device.play(sound) @classmethod diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index db557542bc..64b3957169 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -284,7 +284,7 @@ class IfcGit: if re.match("^Ifc", obj.name): bpy.data.objects.remove(obj, do_unlink=True) - bpy.data.orphans_purge(do_recursive=True) + bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument] settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC")) settings.should_setup_viewport_camera = False diff --git a/src/bonsai/scripts/bonsai_deps.py b/src/bonsai/scripts/bonsai_deps.py new file mode 100644 index 0000000000..678fa49bb1 --- /dev/null +++ b/src/bonsai/scripts/bonsai_deps.py @@ -0,0 +1,23 @@ +"""Clone or update Bonsai external dependencies. + +Must be run from the repository root. +""" + +import subprocess +from pathlib import Path + +DEPS = [ + ("https://projects.blender.org/pioverfour/sun_position.git", "sun_position"), + ("https://github.com/kevancress/MeasureIt_ARCH", "MeasureIt_ARCH"), + ("https://github.com/nortikin/sverchok.git", "sverchok"), +] + +base = Path("src/bonsai/external_dependencies") +base.mkdir(parents=True, exist_ok=True) + +for url, name in DEPS: + path = base / name + if not path.exists(): + subprocess.check_call(["git", "clone", url, str(path)]) + else: + subprocess.check_call(["git", "-C", str(path), "pull", "--rebase"]) diff --git a/src/bonsai/scripts/bonsai_translations.py b/src/bonsai/scripts/bonsai_translations.py index fbf11df6e8..7b6ae960db 100644 --- a/src/bonsai/scripts/bonsai_translations.py +++ b/src/bonsai/scripts/bonsai_translations.py @@ -273,10 +273,10 @@ if BPY_IS_LOADED: f"Couldn't find locale path in the source directory, creating dummy directory: {source_locale_path}.", ) - from ui_translate.settings import ( # pyright: ignore[reportMissingImports] + from ui_translate.settings import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] settings as ui_translate_settings, ) - from ui_translate.update_ui import ( # pyright: ignore[reportMissingImports] + from ui_translate.update_ui import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] UI_OT_i18n_updatetranslation_init_settings, ) diff --git a/src/bonsai/scripts/gbxml.py b/src/bonsai/scripts/gbxml.py index 7bd8063a15..6cf104f2c1 100644 --- a/src/bonsai/scripts/gbxml.py +++ b/src/bonsai/scripts/gbxml.py @@ -23,7 +23,9 @@ import bpy # sys.path.append('C:\Program Files\Python37\Lib\site-packages') import lxml.etree -from bspy import Gbxml # pyright: ignore[reportMissingImports] +from bspy import ( # ty: ignore[unresolved-import] + Gbxml, # pyright: ignore[reportMissingImports] +) class GbxmlExporter: diff --git a/src/bonsai/scripts/generate_steel_profiles_library.py b/src/bonsai/scripts/generate_steel_profiles_library.py index 255a448127..ca45648122 100644 --- a/src/bonsai/scripts/generate_steel_profiles_library.py +++ b/src/bonsai/scripts/generate_steel_profiles_library.py @@ -22,7 +22,7 @@ from math import pi from pathlib import Path -import boltspy as bolts # pyright: ignore[reportMissingImports] +import boltspy as bolts # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import ifcopenshell.api import ifcopenshell.api.material import ifcopenshell.api.project diff --git a/src/bonsai/scripts/obj2ifc-meshlab.py b/src/bonsai/scripts/obj2ifc-meshlab.py index 70849e11d9..d707c8bda7 100644 --- a/src/bonsai/scripts/obj2ifc-meshlab.py +++ b/src/bonsai/scripts/obj2ifc-meshlab.py @@ -31,7 +31,7 @@ import ifcopenshell.api.spatial import ifcopenshell.api.unit import ifcopenshell.guid import numpy as np -import pymeshlab # pyright: ignore[reportMissingImports] +import pymeshlab # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] class Obj2Ifc: diff --git a/src/bonsai/scripts/obj2ifc.py b/src/bonsai/scripts/obj2ifc.py index ec5459c4a3..0b3252cd1c 100644 --- a/src/bonsai/scripts/obj2ifc.py +++ b/src/bonsai/scripts/obj2ifc.py @@ -31,7 +31,7 @@ import ifcopenshell.api.spatial import ifcopenshell.api.unit import ifcopenshell.guid import numpy as np -import pywavefront # pyright: ignore[reportMissingImports] +import pywavefront # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] class Obj2Ifc: diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index 3a1efa5d5b..b7dc932024 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -242,7 +242,7 @@ class TemplateListItemSpy(PanelSpy): self.spied_props: list[dict[str, Any]] = [] self.spied_operators: list[dict[str, Any]] = [] if len(signature(blender_panel.draw_item).parameters) == 8: - blender_panel.draw_item( + blender_panel.draw_item( # ty:ignore[missing-argument] self, bpy.context, self, diff --git a/src/bonsai/type-check-requirements.txt b/src/bonsai/type-check-requirements.txt new file mode 100644 index 0000000000..1a8419c7d3 --- /dev/null +++ b/src/bonsai/type-check-requirements.txt @@ -0,0 +1,41 @@ +aiohttp +beautifulsoup4 +boto3 +botocore +brickschema +cjio >=0.8, <0.10 +debugpy +ezdxf +fake-bpy-module-latest +git+https://github.com/prochitecture/bpypolyskel +git+https://github.com/Andrej730/IFC2JSON_python.git@pyproject_toml +gitpython +isodate +lark +lxml +lxml-stubs +markdown-it-py +natsort +numpy +odfpy +openpyxl +pandas +pillow +platformdirs +pygments +pyradiance +pystache +pytest +pytest_bdd +pytest_blender +python-dateutil +python-socketio +pytz +rdflib +requests +shapely +svgwrite +typing-extensions +typst +tzfpy +xsdata diff --git a/src/bsdd/bsdd_json.py b/src/bsdd/bsdd_json.py index becaac810b..6a89d19753 100644 --- a/src/bsdd/bsdd_json.py +++ b/src/bsdd/bsdd_json.py @@ -7,7 +7,7 @@ from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator -from .type_hints import * +from type_hints import * def _lower_first(s: str) -> str: diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index 28a3512e2e..51638f6954 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -111,8 +111,8 @@ __all__ = [ ] try: - from .stream import stream, stream_entity - from .stream import stream as _stream + from .stream import stream, stream_entity # ty: ignore[possibly-missing-import] + from .stream import stream as _stream # ty: ignore[possibly-missing-import] except: pass diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py index 7cb9368f68..e756cb07cf 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py @@ -20,11 +20,11 @@ from __future__ import annotations import math from typing import TYPE_CHECKING, Any, Literal, Optional, Union -import bmesh # pyright: ignore[reportMissingImports] -import bpy # pyright: ignore[reportMissingImports] +import bmesh # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] +import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import numpy as np import numpy.typing as npt -from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] +from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import ifcopenshell.util.shape_builder import ifcopenshell.util.unit diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py index 0f09bd4991..8a8f2307a4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py @@ -431,7 +431,7 @@ class Usecase: ) ifcopenshell.api.type.assign_type( self.file, - should_run_listeners=False, + should_run_listeners=False, # ty:ignore[unknown-argument] related_objects=[element], relating_type=new_type, should_map_representations=False, diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/add_surface_textures.py b/src/ifcopenshell-python/ifcopenshell/api/style/add_surface_textures.py index 3db088f83e..7ef4858041 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/add_surface_textures.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/add_surface_textures.py @@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any, Optional import ifcopenshell if TYPE_CHECKING: - import bpy # pyright: ignore[reportMissingImports] + import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] def add_surface_textures( diff --git a/src/ifcopenshell-python/ifcopenshell/geom/__init__.py b/src/ifcopenshell-python/ifcopenshell/geom/__init__.py index 606004864c..2b01d63925 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/__init__.py @@ -33,14 +33,14 @@ def _has_occ(): # Previous versions (pythonocc<=0.17.3) are using just OCC. try: - import OCC.Core.BRepTools # pyright: ignore[reportMissingImports] + import OCC.Core.BRepTools # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] return True except ImportError: pass try: - import OCC.BRepTools # noqa: F401 # pyright: ignore[reportMissingImports] + import OCC.BRepTools # noqa: F401 # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] return True except ImportError: diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index 36f9b48070..9c36e4b933 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -28,7 +28,7 @@ from ..file import file from . import has_occ if TYPE_CHECKING: - from OCC.Core import TopoDS # pyright: ignore[reportMissingImports] + from OCC.Core import TopoDS # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] IteratorOutput = Union["ShapeElementType", "utils.shape_tuple"] @@ -47,9 +47,9 @@ if has_occ: from . import occ_utils as utils try: - from OCC.Core import TopoDS # pyright: ignore[reportMissingImports] + from OCC.Core import TopoDS # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] except ImportError: - from OCC import TopoDS # pyright: ignore[reportMissingImports] + from OCC import TopoDS # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] def wrap_shape_creation(settings: settings, shape: ifcopenshell_wrapper.Element): if getattr(settings, "use_python_opencascade", False): diff --git a/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py b/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py index 9a4a44c18f..15a4dfc838 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py @@ -26,17 +26,33 @@ import warnings from collections.abc import Iterable from typing import NamedTuple, Union -import OCC # pyright: ignore[reportMissingImports] +import OCC # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] from typing_extensions import assert_never import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper try: - from OCC.Core import AIS, BRepTools, Graphic3d, Quantity, TopoDS, V3d, gp # pyright: ignore[reportMissingImports] + from OCC.Core import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] + AIS, + BRepTools, + Graphic3d, + Quantity, + TopoDS, + V3d, + gp, + ) USE_OCCT_HANDLE = False except ImportError: - from OCC import AIS, BRepTools, Graphic3d, Quantity, TopoDS, V3d, gp # pyright: ignore[reportMissingImports] + from OCC import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] + AIS, + BRepTools, + Graphic3d, + Quantity, + TopoDS, + V3d, + gp, + ) USE_OCCT_HANDLE = True @@ -68,7 +84,7 @@ DEFAULT_STYLES = { def initialize_display(): - import OCC.Display.SimpleGui # pyright: ignore[reportMissingImports] + import OCC.Display.SimpleGui # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] global handle, main_loop, add_menu, add_function_to_menu handle, main_loop, add_menu, add_function_to_menu = OCC.Display.SimpleGui.init_display() diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 907cb85b5a..774c0c898e 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -40,7 +40,7 @@ if TYPE_CHECKING: # NOTE: mathutils is never used at runtime in ifcopenshell, # only for type checking to ensure methods are compatible with # Blender vectors. - from mathutils import Vector # pyright: ignore[reportMissingImports] + from mathutils import Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] # Support both numpy arrays and python sequences as inputs. VectorType = Union[Sequence[float], Vector, np.ndarray] diff --git a/src/ifcopenshell-python/ifcopenshell/validate.py b/src/ifcopenshell-python/ifcopenshell/validate.py index a98282fd1c..524dc78d35 100644 --- a/src/ifcopenshell-python/ifcopenshell/validate.py +++ b/src/ifcopenshell-python/ifcopenshell/validate.py @@ -47,6 +47,7 @@ from __future__ import annotations import argparse import functools +import itertools import json import os import sys @@ -331,7 +332,7 @@ def log_internal_cpp_errors( lines = list(open(filename, "rb")) lengths = list(map(len, lines)) cumsum = 0 - cs = [cumsum := cumsum + x for x in lengths] + cs = list(itertools.accumulate(lengths)) for offsets, msg in zip(chr_offsets, msgs): if offsets: diff --git a/src/ifcopenshell-python/test/util/test_shape_builder.py b/src/ifcopenshell-python/test/util/test_shape_builder.py index 5ff8adc03f..6a01a74201 100644 --- a/src/ifcopenshell-python/test/util/test_shape_builder.py +++ b/src/ifcopenshell-python/test/util/test_shape_builder.py @@ -41,7 +41,7 @@ from ifcopenshell.util.shape_builder import ( class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): def test_np_rotation_matrix(self): - from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] + from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] # 2D. assert np.allclose(Matrix.Rotation(radians(45), 2), np_rotation_matrix(radians(45), 2)) @@ -62,7 +62,7 @@ class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): assert np.allclose(Matrix.Rotation(*rotation_vector_args), np_rotation_matrix(*rotation_vector_args)) def test_np_matrix_to_euler(self): - from mathutils import Euler # pyright: ignore[reportMissingImports] + from mathutils import Euler # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] # Test 3x3. rot = Euler((0.5, 0.5, 0.5)).to_matrix() @@ -77,7 +77,7 @@ class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): assert np.allclose(rot.to_euler(), np_matrix_to_euler(V(rot))) def test_np_angle(self): - from mathutils import Vector # pyright: ignore[reportMissingImports] + from mathutils import Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] v1, v2 = (1, 0, 0), (0, 1, 0) angle = np_angle(v1, v2) @@ -100,7 +100,7 @@ class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): assert is_x(angle, radians(90)) def test_np_normal(self): - import mathutils.geometry # pyright: ignore[reportMissingImports] + import mathutils.geometry # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] vectors = (0, 0, 0), (1, 0, 0), (0, 1, 0) n = mathutils.geometry.normal(vectors) @@ -113,7 +113,7 @@ class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): assert np.allclose(n, (0, 0, -1)) def test_np_intersect_line_line(self): - import mathutils.geometry # pyright: ignore[reportMissingImports] + import mathutils.geometry # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] p1, p2 = [0, 0, 0], [1, 1, 1] q1, q2 = [0, 1, 0], [1, 0, 1] diff --git a/src/ifcopenshell-python/type-check-requirements.txt b/src/ifcopenshell-python/type-check-requirements.txt new file mode 100644 index 0000000000..c797c28034 --- /dev/null +++ b/src/ifcopenshell-python/type-check-requirements.txt @@ -0,0 +1,31 @@ +beautifulsoup4 +cjio >=0.8, <0.10 +deepdiff +docutils +flask +isodate +jinja2 +lark +meshio +mysql-connector-python +networkx +numpy +odfpy +openpyxl +pandas +psutil +pydantic +PyP6Xer +pystache +pytest +python-dateutil +requests +scikit-learn +shapely +tabulate +toposort +typing-extensions +typst +xlsxwriter +xmlschema +xsdata diff --git a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py index 8a80b5d264..fac4573a23 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py +++ b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py @@ -67,9 +67,9 @@ class Patcher: def patch(self) -> None: import bonsai.tool as tool - import bpy # pyright: ignore[reportMissingImports] + import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import ifcopenshell.util.element - from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] + from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] if len(bpy.data.objects) > 0: bpy.data.batch_remove(bpy.data.objects) diff --git a/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py b/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py index 5d4844e779..8cb68c3dfc 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py +++ b/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py @@ -26,7 +26,7 @@ import ifcopenshell import ifcopenshell.util.shape_builder if TYPE_CHECKING: - import bpy # pyright: ignore[reportMissingImports] + import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] class Patcher: @@ -114,9 +114,9 @@ class Patcher: self.should_create_edges = should_create_edges def patch(self) -> None: - import bmesh # pyright: ignore[reportMissingImports] + import bmesh # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import bonsai.tool as tool - import bpy # pyright: ignore[reportMissingImports] + import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import ifcopenshell.util.schema import ifcopenshell.util.unit @@ -167,9 +167,9 @@ class Patcher: self.file = tool.Ifc.get() def create_edges(self, obj: bpy.types.Object) -> None: - import bmesh # pyright: ignore[reportMissingImports] + import bmesh # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import bonsai.tool as tool - import bpy # pyright: ignore[reportMissingImports] + import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import ifcopenshell.api.geometry import ifcopenshell.api.root import ifcopenshell.util.representation @@ -235,7 +235,7 @@ class Patcher: # No sharp faces from math import degrees - import bmesh # pyright: ignore[reportMissingImports] + import bmesh # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import bonsai.tool as tool import ifcopenshell.api.geometry import ifcopenshell.api.root @@ -282,13 +282,13 @@ class Patcher: # This is crazy but we need a sharp face per island from math import degrees, radians, sin - import bmesh # pyright: ignore[reportMissingImports] + import bmesh # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import bonsai.tool as tool import ifcopenshell.api.geometry import ifcopenshell.api.root import ifcopenshell.util.representation import ifcopenshell.util.shape_builder - from mathutils import Matrix # pyright: ignore[reportMissingImports] + from mathutils import Matrix # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] # Get the active object (assumed to have a mesh) mesh = obj.data diff --git a/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py b/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py index a1912d3b6e..cd801d9fe3 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py +++ b/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py @@ -80,9 +80,9 @@ class Patcher: def patch(self) -> None: from math import degrees - import bmesh # pyright: ignore[reportMissingImports] + import bmesh # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import bonsai.tool as tool - import bpy # pyright: ignore[reportMissingImports] + import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] props = tool.Project.get_project_props() props.should_use_native_meshes = True diff --git a/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py b/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py index 38b01135b3..8da51a4ac8 100644 --- a/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py +++ b/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py @@ -97,5 +97,5 @@ class Patcher: relating_type=relating_type, related_objects=related_objects, should_map_representations=False, - should_run_listeners=False, + should_run_listeners=False, # ty:ignore[unknown-argument] ) diff --git a/src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py b/src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py index 785d5db718..134ad05d99 100644 --- a/src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py +++ b/src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py @@ -193,7 +193,7 @@ class SvIfcBMeshToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.help blender_object=obj, geometry=obj.data, context=context, - should_run_listeners=False, + should_run_listeners=False, # ty:ignore[unknown-argument] ) if not representation: raise Exception("Couldn't create representation. Possibly wrong context.") diff --git a/src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py b/src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py index be06fac1ce..eb0965b8b3 100644 --- a/src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py +++ b/src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py @@ -143,7 +143,7 @@ class SvIfcSverchokToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.h for item in obj: representation = ifcopenshell.api.geometry.add_mesh_representation( self.file, - should_run_listeners=False, + should_run_listeners=False, # ty:ignore[unknown-argument] context=self.context, vertices=[list(map(tuple, item[0]))], edges=[list(map(tuple, item[1]))], From cb113ae8da95bb945004f61fd79669737946038b Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 19 Mar 2026 18:16:24 +0500 Subject: [PATCH 41/76] ifcopenshell_wrapper.pyi - support stubs for constructors --- .../ifcopenshell/ifcopenshell_wrapper.pyi | 29 +++++++++++++++++++ .../util/scripts/validate_stub.py | 9 +++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index f2ae415ead..4933c0b6b0 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -113,6 +113,7 @@ class IfcSpfHeader: def write(self, out): ... class BRep(Representation): + def __init__(self, settings, entity, id, shapes): ... def as_compound(self, force_meters): ... def begin(self): ... def calculate_projected_surface_area(self, ax, along_x, along_y, along_z): ... @@ -125,6 +126,7 @@ class BRep(Representation): def size(self): ... class BRepElement(Element): + def __init__(self, id, parent_id, name, type, guid, context, trsf, geometry, product): ... def calculate_projected_surface_area(self, along_x, along_y, along_z): ... @property def geometry(self) -> BRep: ... @@ -135,6 +137,7 @@ class BRepElement(Element): def volume(self): ... class ColladaSerializer(WriteOnlyGeometrySerializer): + def __init__(self, dae_filename, geometry_settings, settings): ... def finalize(self): ... def isTesselated(self): ... def object_id(self, o): ... @@ -206,6 +209,7 @@ class DoubleArray3: def swap(self, v): ... class Element: + def __init__(self, settings, id, parent_id, name, type, guid, context, trsf, product): ... # TODO: Remove from the wrapper? def SetParents(self, newparents): ... # TODO: could it be None? @@ -275,6 +279,7 @@ class GeometrySerializer: def write(self, *args): ... class GltfSerializer(WriteOnlyGeometrySerializer): + def __init__(self, filename, geometry_settings, settings): ... def finalize(self): ... def isTesselated(self): ... def ready(self): ... @@ -284,6 +289,7 @@ class GltfSerializer(WriteOnlyGeometrySerializer): def writeHeader(self): ... class HdfSerializer(GeometrySerializer): + def __init__(self, hdf_filename, geometry_settings, settings, read_only=False): ... def finalize(self): ... def isTesselated(self): ... def read(self, *args): ... @@ -307,6 +313,7 @@ class IfcBaseType(entity_instance): class IfcEntityInstanceData: ... class IfcLateBoundEntity(IfcBaseEntity): + def __init__(self, decl, data): ... def declaration(self): ... class InstanceStreamer: @@ -386,6 +393,7 @@ class OpaqueNumber: def to_string(self): ... class Representation: + def __init__(self, settings, entity, id): ... def entity(self): ... @property def id(self) -> str: @@ -398,6 +406,7 @@ class Representation: def settings(self): ... class RocksDBPrefixIterator: + def __init__(self, storage, prefix): ... def key(self): ... def next(self): ... def valid(self): ... @@ -410,6 +419,7 @@ class RocksDbSerializer: def writeHeader(self): ... class Serialization(Representation): + def __init__(self, brep): ... @property def brep_data(self): ... @property @@ -418,6 +428,7 @@ class Serialization(Representation): def surface_styles(self): ... class SerializedElement(Element): + def __init__(self, shape_model): ... @property def geometry(self) -> Serialization: ... @@ -434,6 +445,7 @@ class Settings: def setting_names(self): ... class SvgSerializer(WriteOnlyGeometrySerializer): + def __init__(self, out_filename, geometry_settings, settings): ... SH_NONE: Any SH_FULL: Any SH_LEFT: Any @@ -508,6 +520,7 @@ class SwigPyIterator: def value(self): ... class Transformation: + def __init__(self, settings, matrix): ... def data(self): ... @property def matrix(self): ... @@ -572,6 +585,7 @@ class TriangulationElement(Element): def geometry_pointer(self): ... class TtlWktSerializer(WriteOnlyGeometrySerializer): + def __init__(self, filename, geometry_settings, settings): ... def finalize(self): ... def isTesselated(self): ... def ready(self): ... @@ -582,6 +596,7 @@ class TtlWktSerializer(WriteOnlyGeometrySerializer): def writeHeader(self): ... class WaveFrontOBJSerializer(WriteOnlyGeometrySerializer): + def __init__(self, obj_filename, mtl_filename, geometry_settings, settings): ... def finalize(self): ... def isTesselated(self): ... def ready(self): ... @@ -595,6 +610,7 @@ class WriteOnlyGeometrySerializer(GeometrySerializer): def read(self, *args): ... class XmlSerializer: + def __init__(self, file, xml_filename): ... def finalize(self): ... def ready(self): ... def setFile(self, arg2): ... @@ -610,6 +626,7 @@ class abstract_arrangement: def write(self, polygons, progress): ... class aggregation_type(parameter_type): + def __init__(self, type_of_aggregation, bound1, bound2, type_of_element): ... array_type: Any bag_type: Any list_type: Any @@ -622,6 +639,7 @@ class aggregation_type(parameter_type): def type_of_element(self) -> parameter_type: ... class attribute: + def __init__(self, name, type_of_attribute, optional): ... def name(self) -> str: ... def optional(self) -> bool: ... def type_of_attribute(self) -> parameter_type: ... @@ -760,6 +778,7 @@ class cylinder(surface): def matrix(self): ... class declaration: + def __init__(self, name, index_in_schema): ... def _is(self, *args: Union[str, declaration]) -> bool: ... def as_entity(self) -> Union[entity, None]: ... def as_enumeration_type(self) -> Union[enumeration_type, None]: ... @@ -800,6 +819,7 @@ class ellipse(curve): def matrix(self): ... class entity(declaration): + def __init__(self, name, is_abstract, index_in_schema, supertype): ... def all_attributes(self) -> tuple[attribute, ...]: ... def all_inverse_attributes(self) -> tuple[inverse_attribute, ...]: ... def argument_types(self) -> tuple[str, ...]: @@ -886,6 +906,7 @@ class entity_instance: def unset_attribute_value(self, i): ... class enumeration_type(declaration): + def __init__(self, name, index_in_schema, enumeration_items): ... def argument_types(self) -> tuple[str, ...]: ... def as_enumeration_type(self) -> enumeration_type: ... def enumeration_items(self) -> tuple[str, ...]: ... @@ -898,6 +919,7 @@ class enumeration_type(declaration): ... class extrusion(sweep): + def __init__(self, m, basis, dir, d): ... depth: Any direction: Any def calc_hash(self): ... @@ -1085,6 +1107,7 @@ class horizontal_plan_at_element: ... class implicit_item(geom_item): ... class inverse_attribute: + def __init__(self, name, type_of_aggregation, bound1, bound2, entity_reference, attribute_reference): ... bag_type: Any set_type: Any unspecified_type: Any @@ -1173,6 +1196,7 @@ class matrix4(item): def pre_multiply_scale(self, s): ... class named_type(parameter_type): + def __init__(self, declared_type): ... def _is(self, *args): ... def as_named_type(self) -> named_type: ... def declared_type(self) -> declaration: ... @@ -1276,6 +1300,7 @@ class ray_intersection_results: def swap(self, v): ... class revolve(sweep): + def __init__(self, m, basis, pnt, dir, a): ... angle: Any axis_origin: Any direction: Any @@ -1286,6 +1311,7 @@ class revolve(sweep): def matrix(self): ... class schema_definition: + def __init__(self, name, declarations, factory): ... def declaration_by_name(self, *args: str) -> declaration: """ :return: ``declaration`` but upcasted to the most advanced available type @@ -1307,6 +1333,7 @@ class schema_definition: def name(self) -> str: ... class select_type(declaration): + def __init__(self, name, index_in_schema, select_list): ... def as_select_type(self) -> select_type: ... def select_list(self) -> tuple[declaration, ...]: ... @@ -1321,6 +1348,7 @@ class shell: def print_impl(self, o, indent): ... class simple_type(parameter_type): + def __init__(self, declared_type): ... binary_type: Any boolean_type: Any integer_type: Any @@ -1595,6 +1623,7 @@ class type_by_kind: max: Any class type_declaration(declaration): + def __init__(self, name, index_in_schema, declared_type): ... def argument_types(self): ... def as_type_declaration(self) -> type_declaration: ... def declared_type(self): ... diff --git a/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py b/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py index 5ba2466f29..8eb603810c 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py +++ b/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py @@ -57,9 +57,16 @@ def get_function_node_name(node: ast.FunctionDef) -> Union[SubnameType, None]: :return: Function node name as ``SubnameType`` or ``None``, if function wasn't processed and can be skipped. """ node_name = node.name - if node_name.startswith("_") and node_name not in ("_is",): + is_init = node_name == "__init__" + + if node_name.startswith("_") and node_name not in ("_is",) and not is_init: return None args = [a.arg for a in node.args.args] + + # Skip non-informative constructors. + if is_init and args == ["self"]: + return None + if node.args.vararg: args.append("*args") From 3d7de87b4654f4826f4cd1a1d9ff9b6756282a1f Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 09:46:36 +0500 Subject: [PATCH 42/76] ifcopenshell_wrapper.pyi - add temp MakeVolume stub --- .../ifcopenshell/ifcopenshell_wrapper.pyi | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 4933c0b6b0..6d041c699f 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -379,6 +379,12 @@ class JsonSerializer: def setFile(self, arg2): ... def writeHeader(self): ... +# TODO: MakeVolume is ignored in SWIG, remove from stub once build is bumped. +class MakeVolume: + defaultvalue: Any + description: Any + name: Any + class OpaqueCoordinate_3: def get(self, i): ... def set(self, i, n): ... From 6038373ee5e31796c5823becaf8e5398600f2434 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 19 Mar 2026 18:56:51 +0500 Subject: [PATCH 43/76] ifcopenshell_wrapper.pyi - sync default values, validate_stub - suggest default values --- .../ifcopenshell/ifcopenshell_wrapper.pyi | 36 +++++++++---------- .../util/scripts/validate_stub.py | 9 ++++- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 6d041c699f..74fd9dd49b 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -114,7 +114,7 @@ class IfcSpfHeader: class BRep(Representation): def __init__(self, settings, entity, id, shapes): ... - def as_compound(self, force_meters): ... + def as_compound(self, force_meters=False): ... def begin(self): ... def calculate_projected_surface_area(self, ax, along_x, along_y, along_z): ... def calculate_surface_area(self, arg2): ... @@ -154,7 +154,7 @@ class ConversionResult: def Style(self): ... def StylePtr(self): ... def append(self, trsf): ... - def apply_transform(self, unit_scale): ... + def apply_transform(self, unit_scale=1.0): ... def hasStyle(self): ... def prepend(self, trsf): ... def setStyle(self, newStyle): ... @@ -323,7 +323,7 @@ class InstanceStreamer: def hasSemicolon(self): ... def inverses(self, *args): ... def pushPage(self, page): ... - def readInstancePy(self, type_as_declaration_instance): ... + def readInstancePy(self, type_as_declaration_instance=False): ... def references(self, *args): ... def semicolonCount(self): ... def status(self): ... @@ -497,8 +497,8 @@ class SvgSerializer(WriteOnlyGeometrySerializer): def setPrintSpaceNames(self, b): ... def setProfileThreshold(self, i): ... def setScale(self, s): ... - def setSectionHeight(self, h, storey): ... - def setSectionHeightsFromStoreys(self, offset): ... + def setSectionHeight(self, h, storey=None): ... + def setSectionHeightsFromStoreys(self, offset=1.2): ... def setSectionRef(self, s): ... def setSegmentProjection(self, b): ... def setSpaceNameTransform(self, v): ... @@ -517,10 +517,10 @@ class SvgSerializer(WriteOnlyGeometrySerializer): class SwigPyIterator: def advance(self, n): ... def copy(self): ... - def decr(self, n): ... + def decr(self, n=1): ... def distance(self, x): ... def equal(self, x): ... - def incr(self, n): ... + def incr(self, n=1): ... def next(self): ... def previous(self): ... def value(self): ... @@ -597,7 +597,7 @@ class TtlWktSerializer(WriteOnlyGeometrySerializer): def ready(self): ... def setFile(self, arg2): ... def setUnitNameAndMagnitude(self, arg2, arg3): ... - def ttl_object_id(self, o, postfix): ... + def ttl_object_id(self, o, postfix=None): ... def write(self, *args): ... def writeHeader(self): ... @@ -773,7 +773,7 @@ class context: def write(self, arg2): ... class curve(geom_item): - def print_impl(self, o, classname, indent): ... + def print_impl(self, o, classname, indent=0): ... class cylinder(surface): radius: Any @@ -907,8 +907,8 @@ class entity_instance: def setArgumentAsNull(self, i): ... def setArgumentAsString(self, i, a): ... def set_attribute_value(self, *args): ... - def toString(self, arg2, upper): ... - def to_string(self, valid_spf): ... + def toString(self, arg2, upper=False): ... + def to_string(self, valid_spf=True): ... def unset_attribute_value(self, i): ... class enumeration_type(declaration): @@ -947,7 +947,7 @@ class face: class file: def FreshId(self): ... - def add(self, entity: entity_instance, id: int) -> entity_instance: ... + def add(self, entity: entity_instance, id: int = -1) -> entity_instance: ... def addEntities(self, entities): ... def add_type_ref(self, new_entity): ... def batch(self) -> None: @@ -1038,9 +1038,9 @@ class file: def storage_mode(self): ... def to_string(self): ... @staticmethod - def traverse(instance: entity_instance, max_level: int) -> tuple[entity_instance, ...]: ... + def traverse(instance: entity_instance, max_level: int = -1) -> tuple[entity_instance, ...]: ... @staticmethod - def traverse_breadth_first(instance: entity_instance, max_level: int) -> tuple[entity_instance, ...]: ... + def traverse_breadth_first(instance: entity_instance, max_level: int = -1) -> tuple[entity_instance, ...]: ... def types(self) -> tuple[str, ...]: """Return a tuple of classes present in the file. @@ -1610,7 +1610,7 @@ class tree: def protrusion_distances(self): ... def select(self, *args): ... def select_box(self, *args): ... - def select_ray(self, p0, d, length): ... + def select_ray(self, p0, d, length=1000.0): ... def styles(self): ... def uint8_to_b64(self, uuids_array): ... @staticmethod @@ -1648,7 +1648,7 @@ def create_epeck(*args): ... def create_shape(*args): ... def flatten(deep): ... def get_feature(x): ... -def get_info_cpp(v, include_identifier): ... +def get_info_cpp(v, include_identifier=True): ... def get_log(): ... def guess_file_type(fn): ... def helmert_curve_point(A0, A1, A2, s): ... @@ -1658,14 +1658,14 @@ def line_segments_to_polygons(s, eps, segments): ... def map_shape(settings, instance): ... def nary_union(sequence): ... def new_IfcBaseClass(schema_identifier: str, name: str) -> entity_instance: ... -def open(fn, readonly): ... +def open(fn, readonly=False): ... def parse_ifcxml(filename): ... def polygons_to_svg(*args): ... def read(data): ... def register_schema(arg1): ... def schema_by_name(arg1: str) -> schema_definition: ... def schema_names() -> tuple[str, ...]: ... -def serialise(schema_name, shape_str, advanced): ... +def serialise(schema_name, shape_str, advanced=True): ... def set_feature(x, v): ... def set_log_format_json(): ... def set_log_format_text(): ... diff --git a/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py b/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py index 8eb603810c..168323ec0e 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py +++ b/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py @@ -61,7 +61,14 @@ def get_function_node_name(node: ast.FunctionDef) -> Union[SubnameType, None]: if node_name.startswith("_") and node_name not in ("_is",) and not is_init: return None - args = [a.arg for a in node.args.args] + arg_nodes = node.args.args + defaults = [None] * (len(arg_nodes) - len(node.args.defaults)) + node.args.defaults + args: list[str] = [] + for arg, default in zip(arg_nodes, defaults): + if default is None: + args.append(arg.arg) + else: + args.append(f"{arg.arg}={ast.unparse(default)}") # Skip non-informative constructors. if is_init and args == ["self"]: From 3b7cf6e8655345b0a023238cd7774b5edee0b972 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 19 Mar 2026 19:36:39 +0500 Subject: [PATCH 44/76] Fix error displaying bsdd description after API update (ed81a0a4b) --- src/bonsai/bonsai/tool/bsdd.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/bsdd.py b/src/bonsai/bonsai/tool/bsdd.py index 2d9c5605bf..387474b81f 100644 --- a/src/bonsai/bonsai/tool/bsdd.py +++ b/src/bonsai/bonsai/tool/bsdd.py @@ -269,7 +269,8 @@ class Bsdd(bonsai.core.tool.Bsdd): @classmethod def get_bsdd_property(cls, uri: str) -> dict: if not (bsdd_property := cls.bsdd_properties.get(uri, {})): - bsdd_property = cls.client.get_property(uri, include_classes=True) + # Cache miss occurs for keyword search mode, for classes cache is prepopulated. + bsdd_property = cls.client.get_property(uri) cls.bsdd_properties[uri] = bsdd_property return bsdd_property From 48d45451ac70c24fd46a295700b6ada62d8553fa Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 19 Mar 2026 19:52:25 +0500 Subject: [PATCH 45/76] Remove dead code join_walls_TZ, join_T, join_Z superseded in acdc40fb4 --- src/bonsai/bonsai/bim/module/model/wall.py | 44 ---------------------- src/bonsai/bonsai/core/model.py | 42 --------------------- 2 files changed, 86 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 441566e3e6..4d0c9b3de7 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -1268,27 +1268,6 @@ class DumbWallJoiner: bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=wall2) return wall2 - def join_Z(self, wall1, slab2): - element1 = tool.Ifc.get_entity(wall1) - element2 = tool.Ifc.get_entity(slab2) - - for rel in element1.ConnectedFrom: - if rel.is_a() == "IfcRelConnectsElements" and rel.Description == "TOP": - ifcopenshell.api.geometry.disconnect_element( - tool.Ifc.get(), - relating_element=rel.RelatingElement, - related_element=element1, - ) - - ifcopenshell.api.geometry.connect_element( - tool.Ifc.get(), - relating_element=element2, - related_element=element1, - description="TOP", - ) - - tool.Model.recreate_wall(element1, wall1) - def set_axis(self, wall, p1, p2): axis = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW") builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) @@ -1333,29 +1312,6 @@ class DumbWallJoiner: self.set_axis(element1, p1, p2) tool.Model.recreate_wall(element1, wall1) - def join_T(self, wall1: bpy.types.Object, wall2: bpy.types.Object) -> None: - element1 = tool.Ifc.get_entity(wall1) - element2 = tool.Ifc.get_entity(wall2) - axis1 = tool.Model.get_wall_axis(wall1) - axis2 = tool.Model.get_wall_axis(wall2) - intersect = tool.Cad.intersect_edges(axis1["reference"], axis2["reference"]) - if intersect: - intersect, _ = intersect - else: - return - connection = "ATEND" if tool.Cad.edge_percent(intersect, axis1["reference"]) > 0.5 else "ATSTART" - - ifcopenshell.api.geometry.connect_path( - tool.Ifc.get(), - related_element=element1, - relating_element=element2, - relating_connection="ATPATH", - related_connection=connection, - description="BUTT", - ) - - tool.Model.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"]) - def connect(self, obj1: bpy.types.Object, obj2: bpy.types.Object) -> None: wall1 = tool.Ifc.get_entity(obj1) wall2 = tool.Ifc.get_entity(obj2) diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index eb76202fd3..e975505381 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -159,48 +159,6 @@ def extend_wall_to_slab( model.reload_body_representation(wall_objs) -def join_walls_TZ( - ifc: type[tool.Ifc], - blender: type[tool.Blender], - geometry: type[tool.Geometry], - joiner: DumbWallJoiner, - model: type[tool.Model], -) -> None: - selected_objs = [ - o - for o in blender.get_selected_objects() - if (e := ifc.get_entity(o)) and model.get_usage_type(e) in ("LAYER2", "LAYER3") - ] - if len(selected_objs) < 2: - raise RequireAtLeastTwoLayeredElements( - "Two or more vertically or horizontally layered elements must be selected to connect their paths together" - ) - - for obj in selected_objs: - geometry.clear_scale(obj) - - elements = [ifc.get_entity(o) for o in blender.get_selected_objects()] - layer2_elements = [] - layer3_elements = [] - for element in elements: - usage = model.get_usage_type(element) - if usage == "LAYER2": - layer2_elements.append(element) - elif usage == "LAYER3": - layer3_elements.append(element) - if layer3_elements: - target = ifc.get_object(layer3_elements[0]) - for element in layer2_elements: - joiner.join_Z(ifc.get_object(element), target) - else: - if not (active_obj := blender.get_active_object()): - active_obj = selected_objs[0] - for obj in selected_objs: - if obj == active_obj: - continue - joiner.join_T(obj, active_obj) - - class RequireTwoWallsError(Exception): pass From e9241fd8124611156506652c4f971ba430170757 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 19 Mar 2026 20:10:15 +0500 Subject: [PATCH 46/76] Fix error in bim.fit_flow_segments --- src/bonsai/bonsai/bim/module/model/mep.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index 245e10a21b..87d5f56064 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -227,7 +227,7 @@ class FitFlowSegments(bpy.types.Operator, tool.Ifc.Operator): is_parallel21 = tool.Cad.is_x(angle21, (0, 180), tolerance=0.001) is_parallel23 = tool.Cad.is_x(angle23, (0, 180), tolerance=0.001) - if not all(is_parallel12, is_parallel13, is_parallel21, is_parallel23): + if not all([is_parallel12, is_parallel13, is_parallel21, is_parallel23]): fitting_type = "WYE" if not fitting_type: From ffd246632142c244cf75f35fd92cc2a7f4c3285e Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 19 Mar 2026 20:14:29 +0500 Subject: [PATCH 47/76] Fix missing prop name in bim.mep_add_bend --- src/bonsai/bonsai/bim/module/model/mep.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index 87d5f56064..df34166229 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -903,7 +903,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): start_segment_id: bpy.props.IntProperty(name="Start Segment Element ID", default=0) end_segment_id: bpy.props.IntProperty(name="End Segment Element ID", default=0) radius: bpy.props.FloatProperty( - "Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0 + name="Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0 ) def _execute(self, context): From b043dd4d04d13f51790dbc9191d8d4bffcf777c3 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 10:12:25 +0500 Subject: [PATCH 48/76] Fix unknown-argument error in BaseLinesShader.__init__ --- src/bonsai/bonsai/bim/module/drawing/shaders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/shaders.py b/src/bonsai/bonsai/bim/module/drawing/shaders.py index 774002fea3..5d49946da6 100644 --- a/src/bonsai/bonsai/bim/module/drawing/shaders.py +++ b/src/bonsai/bonsai/bim/module/drawing/shaders.py @@ -367,7 +367,7 @@ class BaseLinesShader(BaseShader): """ def __init__(self, gap_size=16): - super().__init__(gap_size=gap_size) + super().__init__() def glenable(self): super().glenable() From f5be64af6c63396a74b681e31984da01b240653c Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 10:13:59 +0500 Subject: [PATCH 49/76] Remove redundant __init__ from BaseLinesShader --- src/bonsai/bonsai/bim/module/drawing/shaders.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/shaders.py b/src/bonsai/bonsai/bim/module/drawing/shaders.py index 5d49946da6..c050cf0f41 100644 --- a/src/bonsai/bonsai/bim/module/drawing/shaders.py +++ b/src/bonsai/bonsai/bim/module/drawing/shaders.py @@ -366,9 +366,6 @@ class BaseLinesShader(BaseShader): } """ - def __init__(self, gap_size=16): - super().__init__() - def glenable(self): super().glenable() From 6c1fb3b01ab40d0fccdbde3785a1cb0a317402c3 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 10:42:05 +0500 Subject: [PATCH 50/76] Remove stale mass_time_units_in_wizard references (5c31ae4c3) --- src/bonsai/bonsai/bim/ui.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 526b757031..09eee51401 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -787,7 +787,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): doc: DocPreferences default_parameters: DefaultParameters container_hide_show_isolate: bool - mass_time_units_in_wizard: bool chain_filter_with_set_operations: bool save_metadata_blend_file: bool metadata_blend_file_suffix: str @@ -986,7 +985,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "container_hide_show_isolate") - layout.prop(self, "mass_time_units_in_wizard") row = layout.row(align=True) row.prop(self, "chain_filter_with_set_operations") row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/3270" From 2bad8611222559b09df2b45b6ca522412aaf9aff Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 12:01:04 +0500 Subject: [PATCH 51/76] ifcopenshell_wrapper.pyi - support varargs and kwargs in constructors --- .../ifcopenshell/ifcopenshell_wrapper.pyi | 65 ++++++++++++++++++- .../util/scripts/validate_stub.py | 9 ++- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 74fd9dd49b..b2f868ec24 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -104,6 +104,7 @@ class IfcSpfHeader: https://standards.buildingsmart.org/documents/Implementation/ImplementationGuide_IFCHeaderData_Version_1.0.2.pdf """ + def __init__(self, *args): ... def file(self, *args): ... def file_description_py(self): ... def file_name_py(self): ... @@ -153,6 +154,7 @@ class ConversionResult: def Shape(self): ... def Style(self): ... def StylePtr(self): ... + def __init__(self, *args): ... def append(self, trsf): ... def apply_transform(self, unit_scale=1.0): ... def hasStyle(self): ... @@ -162,6 +164,7 @@ class ConversionResult: class ConversionResultShape: def Serialize(self, place, arg3): ... def Triangulate(self, *args): ... + def __init__(self, *args, **kwargs): ... def add(self, arg2): ... def area(self): ... def axis(self): ... @@ -196,6 +199,7 @@ class ConversionResultShape: def wrap_in_compound(self): ... class DoubleArray3: + def __init__(self, *args): ... def back(self): ... def begin(self): ... def empty(self): ... @@ -270,6 +274,7 @@ class Element: class GeometrySerializer: READ_BREP: Any READ_TRIANGULATION: Any + def __init__(self, *args, **kwargs): ... def geometry_settings(self, *args): ... def isTesselated(self): ... def object_id(self, o): ... @@ -301,6 +306,7 @@ class HdfSerializer(GeometrySerializer): def writeHeader(self): ... class IfcBaseEntity(entity_instance): + def __init__(self, *args, **kwargs): ... def declaration(self): ... def get(self, name): ... def get_inverse(self, name): ... @@ -308,15 +314,18 @@ class IfcBaseEntity(entity_instance): def set_id(self, i): ... class IfcBaseType(entity_instance): + def __init__(self, *args, **kwargs): ... def declaration(self): ... -class IfcEntityInstanceData: ... +class IfcEntityInstanceData: + def __init__(self, *args, **kwargs): ... class IfcLateBoundEntity(IfcBaseEntity): def __init__(self, decl, data): ... def declaration(self): ... class InstanceStreamer: + def __init__(self, *args): ... def bypassTypes(self, type_names): ... def bypassed_instances(self): ... coerce_attribute_count: bool @@ -329,6 +338,7 @@ class InstanceStreamer: def status(self): ... class Iterator: + def __init__(self, *args): ... initialization_outcome_: Any processed_: Any def bounds_max(self): ... @@ -374,6 +384,7 @@ class Iterator: class JsonSerializer: JSON_DIALECT_CREOOX: Any + def __init__(self, *args): ... def finalize(self): ... def ready(self): ... def setFile(self, arg2): ... @@ -386,14 +397,17 @@ class MakeVolume: name: Any class OpaqueCoordinate_3: + def __init__(self, *args): ... def get(self, i): ... def set(self, i, n): ... class OpaqueCoordinate_4: + def __init__(self, *args): ... def get(self, i): ... def set(self, i, n): ... class OpaqueNumber: + def __init__(self, *args, **kwargs): ... def clone(self): ... def to_double(self): ... def to_string(self): ... @@ -419,6 +433,7 @@ class RocksDBPrefixIterator: def value(self): ... class RocksDbSerializer: + def __init__(self, *args): ... def finalize(self): ... def ready(self): ... def setFile(self, arg2): ... @@ -515,6 +530,7 @@ class SvgSerializer(WriteOnlyGeometrySerializer): def writeHeader(self): ... class SwigPyIterator: + def __init__(self, *args, **kwargs): ... def advance(self, n): ... def copy(self): ... def decr(self, n=1): ... @@ -532,6 +548,7 @@ class Transformation: def matrix(self): ... class Triangulation(Representation): + def __init__(self, *args): ... def addEdge(self, item_id, style, i0, i1): ... def addFace(self, *args): ... def addNormal(self, X, Y, Z): ... @@ -586,6 +603,7 @@ class Triangulation(Representation): def verts_buffer(self) -> bytes: ... class TriangulationElement(Element): + def __init__(self, *args): ... @property def geometry(self) -> Triangulation: ... def geometry_pointer(self): ... @@ -613,6 +631,7 @@ class WaveFrontOBJSerializer(WriteOnlyGeometrySerializer): def writeMaterial(self, style): ... class WriteOnlyGeometrySerializer(GeometrySerializer): + def __init__(self, *args, **kwargs): ... def read(self, *args): ... class XmlSerializer: @@ -625,6 +644,7 @@ class XmlSerializer: class _SwigNonDynamicMeta(type): ... class abstract_arrangement: + def __init__(self, *args, **kwargs): ... def get_face_pairs(self): ... def merge(self, edge_indices): ... def num_edges(self): ... @@ -686,11 +706,13 @@ class bspline_surface(surface): def kind(self): ... class buffer: + def __init__(self, *args): ... def filename(self): ... def get_value(self): ... def is_ready(self): ... class cant_function(function_item): + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... def end(self): ... @@ -718,6 +740,7 @@ class clash: p2: Any class clashes: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -751,6 +774,7 @@ class collection: def matrix(self): ... class colour(item): + def __init__(self, *args): ... def r(self) -> float: ... def g(self) -> float: ... def b(self) -> float: ... @@ -764,6 +788,7 @@ class colour(item): def kind(self) -> int: ... class context: + def __init__(self, *args): ... def add(self, segments): ... def build(self): ... def get_face_pairs(self): ... @@ -773,6 +798,7 @@ class context: def write(self, arg2): ... class curve(geom_item): + def __init__(self, *args, **kwargs): ... def print_impl(self, o, classname, indent=0): ... class cylinder(surface): @@ -800,6 +826,7 @@ class declaration: def type(self): ... class direction3: + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... @property @@ -811,6 +838,7 @@ class drawing_meta: pln_3d: Any class edge(trimmed_curve): + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... def kind(self): ... @@ -854,6 +882,7 @@ class entity(declaration): def supertype(self) -> Union[entity, None]: ... class entity_instance: + def __init__(self, *args, **kwargs): ... file: ifcopenshell.file """Reference to IFC file to prevent it's garbage collection, if entity is still used.""" @@ -947,6 +976,7 @@ class face: class file: def FreshId(self): ... + def __init__(self, *args): ... def add(self, entity: entity_instance, id: int = -1) -> entity_instance: ... def addEntities(self, entities): ... def add_type_ref(self, new_entity): ... @@ -1057,9 +1087,11 @@ class file_open_status: UNSUPPORTED_SCHEMA: int INVALID_SYNTAX: int UNKNOWN: int + def __init__(self, *args): ... def value(self): ... class fn_evaluator: + def __init__(self, *args, **kwargs): ... settings_: Any def clone(self): ... def end(self): ... @@ -1068,6 +1100,7 @@ class fn_evaluator: def start(self): ... class function_item(implicit_item): + def __init__(self, *args, **kwargs): ... def calc_hash(self): ... def end(self): ... def kind(self): ... @@ -1075,10 +1108,12 @@ class function_item(implicit_item): def start(self): ... class function_item_evaluator: + def __init__(self, *args): ... def evaluate(self, *args): ... def evaluation_points(self, *args): ... class functor_item(function_item): + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... def end(self): ... @@ -1086,6 +1121,7 @@ class functor_item(function_item): def start(self): ... class geom_item(item): + def __init__(self, *args, **kwargs): ... matrix: Any surface_style: Any @@ -1099,6 +1135,7 @@ class geometry_conversion_result: representation: Any class gradient_function(function_item): + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... def end(self): ... @@ -1110,7 +1147,9 @@ class gradient_function(function_item): class equal_functor: ... class hash_functor: ... class horizontal_plan_at_element: ... -class implicit_item(geom_item): ... + +class implicit_item(geom_item): + def __init__(self, *args, **kwargs): ... class inverse_attribute: def __init__(self, name, type_of_aggregation, bound1, bound2, entity_reference, attribute_reference): ... @@ -1126,6 +1165,7 @@ class inverse_attribute: def type_of_aggregation_string(self): ... class item: + def __init__(self, *args, **kwargs): ... instance: Any orientation: Any def calc_hash(self): ... @@ -1148,6 +1188,7 @@ class line(curve): def matrix(self): ... class line_segment: + def __init__(self, *args): ... def back(self): ... def begin(self): ... def empty(self): ... @@ -1190,6 +1231,7 @@ class matrix4(item): AFFINE_W_UNIFORM_SCALE: Any AFFINE_W_NONUNIFORM_SCALE: Any OTHER: Any + def __init__(self, *args): ... tag: Any def calc_hash(self): ... def clone_(self): ... @@ -1221,6 +1263,7 @@ class offset_curve(curve): def kind(self): ... class offset_function(function_item): + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... def end(self): ... @@ -1242,6 +1285,7 @@ class parameter_type: def as_simple_type(self) -> Union[simple_type, None]: ... class piecewise_function(function_item): + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... def end(self): ... @@ -1261,6 +1305,7 @@ class plane(surface): def matrix(self): ... class point3: + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... @property @@ -1282,6 +1327,7 @@ class ray_intersection_result: style_index: Any class ray_intersection_results: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1384,6 +1430,7 @@ class sphere(surface): def matrix(self): ... class style(item): + def __init__(self, *args): ... diffuse: colour name: str """E.g. 'IfcSurfaceStyleShading-218', where 218 is style's STEP id.""" @@ -1418,9 +1465,11 @@ class style(item): def kind(self) -> int: ... -class surface(geom_item): ... +class surface(geom_item): + def __init__(self, *args, **kwargs): ... class svg_groups_of_line_segments: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1445,6 +1494,7 @@ class svg_groups_of_line_segments: def swap(self, v): ... class svg_groups_of_polygons: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1469,6 +1519,7 @@ class svg_groups_of_polygons: def swap(self, v): ... class svg_line_segments: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1493,6 +1544,7 @@ class svg_line_segments: def swap(self, v): ... class svg_loop: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1517,6 +1569,7 @@ class svg_loop: def swap(self, v): ... class svg_loops: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1541,6 +1594,7 @@ class svg_loops: def swap(self, v): ... class svg_point: + def __init__(self, *args): ... def back(self): ... def begin(self): ... def empty(self): ... @@ -1554,6 +1608,7 @@ class svg_point: def swap(self, v): ... class svg_polygons: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1578,9 +1633,11 @@ class svg_polygons: def swap(self, v): ... class sweep(geom_item): + def __init__(self, *args, **kwargs): ... basis: Any class sweep_along_curve(sweep): + def __init__(self, *args): ... curve: Any surface: Any direction: Any @@ -1598,6 +1655,7 @@ class torus(surface): def matrix(self): ... class tree: + def __init__(self, *args): ... def add_element(self, *args): ... def add_file(self, *args): ... def clash_clearance_many(self, set_a, set_b, clearance, check_all): ... @@ -1618,6 +1676,7 @@ class tree: def write_h5(self): ... class trimmed_curve(geom_item): + def __init__(self, *args, **kwargs): ... basis: Any curve_sense: Any start: Any diff --git a/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py b/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py index 168323ec0e..1c3b6cb001 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py +++ b/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py @@ -70,13 +70,16 @@ def get_function_node_name(node: ast.FunctionDef) -> Union[SubnameType, None]: else: args.append(f"{arg.arg}={ast.unparse(default)}") + if arg := node.args.vararg: + args.append(f"*{arg.arg}") + + if arg := node.args.kwarg: + args.append(f"**{arg.arg}") + # Skip non-informative constructors. if is_init and args == ["self"]: return None - if node.args.vararg: - args.append("*args") - node_name = f"def {node.name}" node_name = f"{node_name}({', '.join(args)}): ..." From 1a8b17e23598f15369309154a95ed51d14fc269d Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 12:11:58 +0500 Subject: [PATCH 52/76] ifcfm cobie24 - remove unused ifc_file param from get_unit_name --- src/ifcfm/ifcfm/cobie24.py | 2 +- src/ifcfm/ifcfm/cobie24legacy.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ifcfm/ifcfm/cobie24.py b/src/ifcfm/ifcfm/cobie24.py index f4848e1654..b83a4abe08 100644 --- a/src/ifcfm/ifcfm/cobie24.py +++ b/src/ifcfm/ifcfm/cobie24.py @@ -955,7 +955,7 @@ def get_unit_type_name(ifc_file: ifcopenshell.file, unit_type: str) -> Union[str return val(unit.Currency) -def get_unit_name(ifc_file: ifcopenshell.entity_instance, unit: ifcopenshell.entity_instance) -> Union[str, None]: +def get_unit_name(unit: ifcopenshell.entity_instance) -> Union[str, None]: if unit.is_a("IfcNamedUnit"): return val(unit.Name) diff --git a/src/ifcfm/ifcfm/cobie24legacy.py b/src/ifcfm/ifcfm/cobie24legacy.py index 078529e892..b3aa069e75 100644 --- a/src/ifcfm/ifcfm/cobie24legacy.py +++ b/src/ifcfm/ifcfm/cobie24legacy.py @@ -953,7 +953,7 @@ def get_unit_type_name(ifc_file: ifcopenshell.file, unit_type: str) -> Union[str return val(unit.Currency) -def get_unit_name(ifc_file: ifcopenshell.entity_instance, unit: ifcopenshell.entity_instance) -> Union[str, None]: +def get_unit_name(unit: ifcopenshell.entity_instance) -> Union[str, None]: if unit.is_a("IfcNamedUnit"): return val(unit.Name) From ea3f71b4e00fa4da4651fee44b079ad3aac1a8fb Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 12:20:30 +0500 Subject: [PATCH 53/76] rename test files to test_* prefix for pytest discovery and fix missing add_pset name arg --- src/ifcopenshell-python/test/{file_gc.py => test_file_gc.py} | 2 +- .../test/{global_id_updates.py => test_global_id_updates.py} | 0 ..._string_formatting.py => test_instance_string_formatting.py} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename src/ifcopenshell-python/test/{file_gc.py => test_file_gc.py} (99%) rename src/ifcopenshell-python/test/{global_id_updates.py => test_global_id_updates.py} (100%) rename src/ifcopenshell-python/test/{instance_string_formatting.py => test_instance_string_formatting.py} (100%) diff --git a/src/ifcopenshell-python/test/file_gc.py b/src/ifcopenshell-python/test/test_file_gc.py similarity index 99% rename from src/ifcopenshell-python/test/file_gc.py rename to src/ifcopenshell-python/test/test_file_gc.py index 483c268e17..532fa664ec 100644 --- a/src/ifcopenshell-python/test/file_gc.py +++ b/src/ifcopenshell-python/test/test_file_gc.py @@ -95,7 +95,7 @@ def test_bug_2486_a(): file = ifcopenshell.api.project.create_file() mymaterial = ifcopenshell.api.material.add_material(file) - pset = ifcopenshell.api.pset.add_pset(file, product=mymaterial) + pset = ifcopenshell.api.pset.add_pset(file, product=mymaterial, name="Foo") ifcopenshell.api.pset.edit_pset( file, pset=pset, diff --git a/src/ifcopenshell-python/test/global_id_updates.py b/src/ifcopenshell-python/test/test_global_id_updates.py similarity index 100% rename from src/ifcopenshell-python/test/global_id_updates.py rename to src/ifcopenshell-python/test/test_global_id_updates.py diff --git a/src/ifcopenshell-python/test/instance_string_formatting.py b/src/ifcopenshell-python/test/test_instance_string_formatting.py similarity index 100% rename from src/ifcopenshell-python/test/instance_string_formatting.py rename to src/ifcopenshell-python/test/test_instance_string_formatting.py From 91b6c3e25645c5914332de7e57c883f7c5d87ef3 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 12:43:14 +0500 Subject: [PATCH 54/76] bcf v3 tests - fix wrong args, add dead code TODOs --- src/bcf/tests/v3/test_example_files.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/bcf/tests/v3/test_example_files.py b/src/bcf/tests/v3/test_example_files.py index 3f141afda6..06eb5d22dd 100644 --- a/src/bcf/tests/v3/test_example_files.py +++ b/src/bcf/tests/v3/test_example_files.py @@ -173,16 +173,17 @@ def assert_viewpoints(viewpoints): assert viewpoint.snapshot is not None +# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None: expected_vp = mdl.VisualizationInfo( components=mdl.Components( - view_setup_hints=mdl.ViewSetupHints( - spaces_visible=False, - space_boundaries_visible=False, - openings_visible=False, - ), selection=expected_selection, visibility=mdl.ComponentVisibility( + view_setup_hints=mdl.ViewSetupHints( + spaces_visible=False, + space_boundaries_visible=False, + openings_visible=False, + ), exceptions=expected_exception, default_visibility=False, ), @@ -193,6 +194,7 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e camera_direction=mdl.Direction(x=0.6745243072509766, y=-0.6599355936050415, z=-0.33091068267822266), camera_up_vector=mdl.Direction(x=0.2271970510482788, y=-0.24091780185699463, z=0.9435783624649048), field_of_view=60, + aspect_ratio=1.0, ), guid="21dd4807-e9af-439e-a980-04d913a6b1ce", ) @@ -200,16 +202,17 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e assert viewpoint.snapshot is not None +# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None: expected_vp = mdl.VisualizationInfo( components=mdl.Components( - view_setup_hints=mdl.ViewSetupHints( - spaces_visible=False, - space_boundaries_visible=False, - openings_visible=True, - ), selection=expected_selection, visibility=mdl.ComponentVisibility( + view_setup_hints=mdl.ViewSetupHints( + spaces_visible=False, + space_boundaries_visible=False, + openings_visible=True, + ), exceptions=expected_exception, default_visibility=True, ), @@ -220,6 +223,7 @@ def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, ex camera_direction=mdl.Direction(x=0.7232745289802551, y=0.5967116951942444, z=-0.3475759029388428), camera_up_vector=mdl.Direction(x=0.27662187814712524, y=0.21082592010498047, z=0.937567412853241), field_of_view=60, + aspect_ratio=1.0, ), guid="81daa431-bf01-4a49-80a2-1ab07c177717", ) From 722c374fa6417b6d5edf781e3f85d519f32a7429 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 13:27:32 +0500 Subject: [PATCH 55/76] ids_doc_generator - handle invalid entities coming from a test (1ed770d) Exception: About to emit invalid example data: IfcMaterial.Name not optional --- src/ifctester/test/ids_doc_generator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ifctester/test/ids_doc_generator.py b/src/ifctester/test/ids_doc_generator.py index 3788afebbc..a0cd2d004b 100644 --- a/src/ifctester/test/ids_doc_generator.py +++ b/src/ifctester/test/ids_doc_generator.py @@ -151,7 +151,11 @@ class IdsDocGenerator: l = validate.json_logger() validate.validate(ifc, l) for issue in l.statements: - raise Exception("About to emit invalid example data:", issue) + # test_parsing_entities_with_no_attributes uses nameless IfcMaterial; fix for doc generation. + if issue["instance"].is_a("IfcMaterial") and issue.get("attribute") == "IfcMaterial.Name": + issue["instance"].Name = "Unnamed" + else: + raise Exception("About to emit invalid example data:", issue) lines = ifc.wrapped_data.to_string().split("\n")[7:-3] ifc_text = "" From 9fad1569c74432be588f573e68249e828509d7bb Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 13:29:19 +0500 Subject: [PATCH 56/76] ids_doc_generator - fix error due to stale cache (f40281e97) AssertionError: bool(facet(inst)) is expected --- src/ifctester/test/ids_doc_generator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ifctester/test/ids_doc_generator.py b/src/ifctester/test/ids_doc_generator.py index a0cd2d004b..0d17999ba0 100644 --- a/src/ifctester/test/ids_doc_generator.py +++ b/src/ifctester/test/ids_doc_generator.py @@ -40,6 +40,7 @@ import test_ids from ifcopenshell import validate import ifctester +import ifctester.facet from ifctester import ids outdir = "build" @@ -117,6 +118,8 @@ class FacetDocGenerator: {"name": name, "ids": xml_text, "ifc": ifc_text, "basename": basename, "result": result, "id": inst.id()} ) + ifctester.facet.get_pset.cache_clear() + ifctester.facet.get_psets.cache_clear() assert bool(facet(inst)) is expected def set_facet(self, facet): From 54f450129c2bc05e690a993cf0af8fe99698be39 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 13:29:33 +0500 Subject: [PATCH 57/76] ids_doc_generator - fix failed_entities removed (bd92c043) AttributeError: 'Attribute' object has no attribute 'failed_entities' --- src/ifctester/test/ids_doc_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifctester/test/ids_doc_generator.py b/src/ifctester/test/ids_doc_generator.py index 0d17999ba0..3a08b89544 100644 --- a/src/ifctester/test/ids_doc_generator.py +++ b/src/ifctester/test/ids_doc_generator.py @@ -143,7 +143,7 @@ class IdsDocGenerator: all_applicable.update(spec.applicable_entities) for requirement in spec.requirements: if requirement.status is False: - all_failures.update(requirement.failed_entities) + all_failures.update(f["element"] for f in requirement.failures) assert set(all_applicable) == set(applicable_entities) assert set(all_failures) == set(failed_entities) From 1778656bd5b44b27a9a049588d053f9ef3137463 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 13:30:02 +0500 Subject: [PATCH 58/76] ids_doc_generator - fix Property args missed (ed8eb75) TypeError: Property.__init__() got an unexpected keyword argument 'name' --- src/ifctester/test/ids_doc_generator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ifctester/test/ids_doc_generator.py b/src/ifctester/test/ids_doc_generator.py index 3a08b89544..4c1bcf5575 100644 --- a/src/ifctester/test/ids_doc_generator.py +++ b/src/ifctester/test/ids_doc_generator.py @@ -304,8 +304,8 @@ restriction = ifctester.ids.Restriction(options={"pattern": "(-|[0-9]{2,3})\/(-| spec.requirements.append( ifctester.ids.Property( propertySet="Pset_WallCommon", - name="FireRating", - datatype="IfcLabel", + baseName="FireRating", + dataType="IfcLabel", value=restriction, instructions="Fire rating is specified using the Fire Resistance Level as defined in the Australian National Construction Code (NCC) 2019. Valid examples include -/-/-, -/120/120, and 60/60/60", ) From bcf6f6197d6e848cd5c6ba3ce5568b33b9aa6a3e Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 13:32:32 +0500 Subject: [PATCH 59/76] ifctester - move build-ids-docs target to ifctester Makefile Also added a note why it lives in test folder and added it's output to gitignore. --- .gitignore | 2 ++ src/ifcopenshell-python/Makefile | 4 ---- src/ifctester/Makefile | 5 +++++ src/ifctester/test/ids_doc_generator.py | 10 ++++++++++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index a92e504001..7df9ceb806 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ /_installed-vs*-x*/ /build/ /src/examples/build/ +# ifctester docs output +/src/ifctester/test/build/ # output directories /cmake/out/ diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index b814cb3163..643735ba37 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -79,10 +79,6 @@ test-parallel: test-mathutils: pytest -p no:pytest-blender test/util/test_shape_builder.py -.PHONY: build-ids-docs -build-ids-docs: - mkdir -p test/build - cd test && python ids_doc_generator.py .PHONY: qa qa: diff --git a/src/ifctester/Makefile b/src/ifctester/Makefile index a2df0c5820..63ce7bc311 100644 --- a/src/ifctester/Makefile +++ b/src/ifctester/Makefile @@ -96,3 +96,8 @@ webapp-prepare: webapp-build .PHONY: test test: pytest -p no:pytest-blender test + +.PHONY: build-ids-docs +build-ids-docs: + mkdir -p test/build + cd test && python ids_doc_generator.py diff --git a/src/ifctester/test/ids_doc_generator.py b/src/ifctester/test/ids_doc_generator.py index 4c1bcf5575..1b6b16d221 100644 --- a/src/ifctester/test/ids_doc_generator.py +++ b/src/ifctester/test/ids_doc_generator.py @@ -16,6 +16,16 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcTester. If not, see . +""" +Documentation generator for IfcTester IDS facets and test cases. + +This is not a test file. It lives in the test/ directory because it reuses +test cases from test_facet.py and test_ids.py to generate example IFC files, +IDS files, and Markdown documentation into test/build/. + +Run via: make build-ids-docs (from src/ifctester/) +""" + import functools import os import re From c1a97085043d31055fcd6eef5a7576f4f4c1801c Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 13:35:27 +0500 Subject: [PATCH 60/76] ci.yml - build ifctester docs to ensure script doesn't break --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 488b1c2bcd..bb044eeab3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -254,6 +254,7 @@ jobs: cd ../ifcpatch && make test || ERROR=1 pip install -e ../ifctester --no-deps cd ../ifctester && make test || ERROR=1 + make build-ids-docs || ERROR=1 # Run mathutils related tests at the end to ensure no other code is relying on mathutils. cd ../ifcopenshell-python pip install mathutils From 77f0c433145d085fca8d925b633b2ed67a7aed42 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 13:36:04 +0500 Subject: [PATCH 61/76] ids_doc_generator - fix invalid escape sequence SyntaxWarning SyntaxWarning: invalid escape sequence '\/' at line 312. `\/` in a plain string is treated as `/` by accident; replaced with raw string r"..." to be explicit. --- src/ifctester/test/ids_doc_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifctester/test/ids_doc_generator.py b/src/ifctester/test/ids_doc_generator.py index 1b6b16d221..f91decc1a7 100644 --- a/src/ifctester/test/ids_doc_generator.py +++ b/src/ifctester/test/ids_doc_generator.py @@ -310,7 +310,7 @@ spec = ifctester.ids.Specification( ) specs.specifications.append(spec) spec.applicability.append(ifctester.ids.Entity(name="IFCWALLTYPE")) -restriction = ifctester.ids.Restriction(options={"pattern": "(-|[0-9]{2,3})\/(-|[0-9]{2,3})\/(-|[0-9]{2,3})"}) +restriction = ifctester.ids.Restriction(options={"pattern": r"(-|[0-9]{2,3})/(-|[0-9]{2,3})/(-|[0-9]{2,3})"}) spec.requirements.append( ifctester.ids.Property( propertySet="Pset_WallCommon", From 0acbd5ffad2bb1ed8ea9de537acce59616d0852c Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 15:45:20 +0500 Subject: [PATCH 62/76] black . --- src/bonsai/bonsai/bim/module/model/profile.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index 63c3f8dfa4..1369ad3cb6 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -252,7 +252,9 @@ class DumbProfileRegenerator: results.extend(rel.RelatedObjects) return results - def get_element_types_using_profile(self, profile: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: + def get_element_types_using_profile( + self, profile: ifcopenshell.entity_instance + ) -> list[ifcopenshell.entity_instance]: results = [] profile_sets = [ mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile") From 9c22dc6013e84e159d2b2a224fd280d44ccc65d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:56:36 +0000 Subject: [PATCH 63/76] Bump socket.io-parser from 4.2.4 to 4.2.6 in /src/ifctester/webapp Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.4 to 4.2.6. - [Release notes](https://github.com/socketio/socket.io/releases) - [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md) - [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.4...socket.io-parser@4.2.6) --- updated-dependencies: - dependency-name: socket.io-parser dependency-version: 4.2.6 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- src/ifctester/webapp/package-lock.json | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/src/ifctester/webapp/package-lock.json b/src/ifctester/webapp/package-lock.json index 1f567798a8..c167ebcecc 100644 --- a/src/ifctester/webapp/package-lock.json +++ b/src/ifctester/webapp/package-lock.json @@ -1498,7 +1498,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2700,35 +2699,18 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" + "debug": "~4.4.1" }, "engines": { "node": ">=10.0.0" } }, - "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", From 286c69429d5066cfd4b1555c1de4ba3e2ed2145c Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 20 Mar 2026 15:48:38 +0500 Subject: [PATCH 64/76] gitignore bonsai external_dependencies --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7df9ceb806..395086c420 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,9 @@ src/ifcopenshell-python/test/build # bonsai i18n src/bonsai/bonsai/translations.py +# bonsai external dependencies (cloned for just ty checks) +src/bonsai/external_dependencies/ + # bonsai test temp files src/bonsai/test/files/temp src/bonsai/test/files/basic.ifc.cache.blend From 7b6e82a9cc4badcc9a078a502c8ab31be44dc865 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 20 Mar 2026 23:09:15 +1100 Subject: [PATCH 65/76] Fix stair calculated params test to set custom_tread_lock=False Tests using custom first/last tread runs were not setting custom_tread_lock=False, so the custom values were silently ignored since 8f7cf76d9 introduced the lock gate in the calculation. Co-Authored-By: Claude Opus 4.6 --- src/bonsai/test/tool/test_model.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py index 756268cbee..30782b8a15 100644 --- a/src/bonsai/test/tool/test_model.py +++ b/src/bonsai/test/tool/test_model.py @@ -176,6 +176,7 @@ class TestStairCalculatedParams(NewFile): pset_data = pset_data_base.copy() calculated_data = calculated_data_base.copy() pset_data["custom_first_last_tread_run"] = (0.1, 0.4) + pset_data["custom_tread_lock"] = False calculated_data["Length"] += -0.2 + 0.1 self.compare_data(pset_data, calculated_data) @@ -183,6 +184,7 @@ class TestStairCalculatedParams(NewFile): pset_data = pset_data_base.copy() calculated_data = calculated_data_base.copy() pset_data["custom_first_last_tread_run"] = (0.0, None) + pset_data["custom_tread_lock"] = False calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each self.compare_data(pset_data, calculated_data) @@ -190,6 +192,7 @@ class TestStairCalculatedParams(NewFile): pset_data = pset_data_base.copy() calculated_data = calculated_data_base.copy() pset_data["custom_first_last_tread_run"] = (None, 0.0) + pset_data["custom_tread_lock"] = False calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each self.compare_data(pset_data, calculated_data) @@ -197,6 +200,7 @@ class TestStairCalculatedParams(NewFile): pset_data = pset_data_base.copy() calculated_data = calculated_data_base.copy() pset_data["custom_first_last_tread_run"] = (0.0, 0.0) + pset_data["custom_tread_lock"] = False calculated_data["Length"] = 0.6 # Only 2 middle treads at 0.3 each self.compare_data(pset_data, calculated_data) From d0f20371bdcbb62364d88d6feea00d797c106ff0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 20 Mar 2026 23:10:00 +1100 Subject: [PATCH 66/76] Add feature to get parent of a particular IFC class --- .../ifcopenshell/util/element.py | 18 +++++++++-- .../ifcopenshell/util/selector.py | 8 ++--- .../test/util/test_element.py | 30 +++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index 149606cb14..1c52ebc49d 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -1234,7 +1234,9 @@ def get_controls(element: ifcopenshell.entity_instance) -> Generator[ifcopenshel yield rel.RelatingControl -def get_parent(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: +def get_parent( + element: ifcopenshell.entity_instance, ifc_class: Optional[str] = None +) -> Union[ifcopenshell.entity_instance, None]: """Get the parent in the spatial heirarchy IFC features a spatial hierarchy tree of all objects. Each spatial element @@ -1251,6 +1253,8 @@ def get_parent(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.enti - Voiding: the opening voids another physical element, such as a hole in a wall :param element: Any physical or spatial element in the tree + :param ifc_class: Optionally filter the type of parent you're after. For + example, you may be after the storey, not a space. :return: Its parent. This must exist for any valid file, or None if we've reached the IfcProject. Example: @@ -1260,7 +1264,7 @@ def get_parent(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.enti element = file.by_type("IfcWall")[0] parent = ifcopenshell.util.element.get_parent(element) """ - return ( + parent = ( get_container(element, should_get_direct=True) or get_aggregate(element) or get_nest(element) @@ -1268,6 +1272,16 @@ def get_parent(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.enti or get_voided_element(element) ) + if not ifc_class: + return parent + + while parent: + if parent.is_a(ifc_class): + return parent + parent = get_parent(parent) + + return None + def get_filled_void(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: """If the element is filling a void, get the void diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index c6ee820ab8..bbe8125927 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -440,13 +440,13 @@ def _get_element_value(element: ifcopenshell.entity_instance, keys: list[str]) - elif key == "container": value = ifcopenshell.util.element.get_container(value) elif key == "space": - value = ifcopenshell.util.element.get_container(value, ifc_class="IfcSpace") + value = ifcopenshell.util.element.get_parent(value, ifc_class="IfcSpace") elif key == "storey": - value = ifcopenshell.util.element.get_container(value, ifc_class="IfcBuildingStorey") + value = ifcopenshell.util.element.get_parent(value, ifc_class="IfcBuildingStorey") elif key == "building": - value = ifcopenshell.util.element.get_container(value, ifc_class="IfcBuilding") + value = ifcopenshell.util.element.get_parent(value, ifc_class="IfcBuilding") elif key == "site": - value = ifcopenshell.util.element.get_container(value, ifc_class="IfcSite") + value = ifcopenshell.util.element.get_parent(value, ifc_class="IfcSite") elif key == "parent": value = ifcopenshell.util.element.get_parent(value) elif key in ("types", "occurrences"): diff --git a/src/ifcopenshell-python/test/util/test_element.py b/src/ifcopenshell-python/test/util/test_element.py index 1308e957c1..5eb6034402 100644 --- a/src/ifcopenshell-python/test/util/test_element.py +++ b/src/ifcopenshell-python/test/util/test_element.py @@ -38,6 +38,7 @@ import ifcopenshell.api.sequence import ifcopenshell.api.spatial import ifcopenshell.api.style import ifcopenshell.api.type +import ifcopenshell.api.feature import ifcopenshell.guid import ifcopenshell.util.element as subject import test.bootstrap @@ -891,6 +892,35 @@ class TestGetlayers(test.bootstrap.IFC4, TestGetlayersIFC2X3): assert subject.get_layers(self.file, element) == [layer] +class TestGetParentIFC4(test.bootstrap.IFC4): + def test_getting_the_parent_of_an_element(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + ifcopenshell.api.spatial.assign_container(self.file, products=[element], relating_structure=building) + assert subject.get_parent(element) == building + + def test_getting_the_specific_parent_of_an_element(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + storey = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuildingStorey") + ifcopenshell.api.aggregate.assign_object(self.file, products=[storey], relating_object=building) + ifcopenshell.api.spatial.assign_container(self.file, products=[element], relating_structure=storey) + assert subject.get_parent(element, ifc_class="IfcBuilding") == building + assert subject.get_parent(element, ifc_class="IfcSite") == None + + def test_getting_the_specific_parent_of_an_element_via_voiding(self): + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + ifcopenshell.api.spatial.assign_container(self.file, products=[wall], relating_structure=building) + opening = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcOpeningElement") + ifcopenshell.api.feature.add_feature(self.file, feature=opening, element=wall) + window = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWindow") + ifcopenshell.api.feature.add_filling(self.file, opening=opening, element=window) + assert subject.get_parent(window, ifc_class="IfcWall") == wall + assert subject.get_parent(window, ifc_class="IfcBuilding") == building + assert subject.get_parent(window, ifc_class="IfcSite") == None + + class TestGetContainerIFC4(test.bootstrap.IFC4): def test_getting_the_spatial_container_of_an_element(self): element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") From c026dd3b6e7116b170ebc899d488f0cb31d46d1f Mon Sep 17 00:00:00 2001 From: Parag Debnath <248921312+paragforwork@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:03:40 +0530 Subject: [PATCH 67/76] IsVentilated now defaults to False (#7819) * IsVantillated now defaults to false * IsVentilated now defaults to False --------- Co-authored-by: Parag Debnath --- .../bonsai/bim/module/material/operator.py | 29 ++++++++++++++++++- src/bonsai/test/bim/feature/material.feature | 18 ++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index 08cddbb928..7587a7e4ed 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -717,7 +717,11 @@ class EnableEditingMaterialSetItem(bpy.types.Operator): self.props.material_set_item_material = str(material_set_item.Material.id()) self.props.material_set_item_attributes.clear() - bonsai.bim.helper.import_attributes(material_set_item, self.props.material_set_item_attributes) + bonsai.bim.helper.import_attributes( + material_set_item, + self.props.material_set_item_attributes, + callback=self.import_attributes_callback, + ) if material_set_item.is_a("IfcMaterialProfile"): if material_set_item.Profile and material_set_item.Profile.ProfileName: @@ -725,6 +729,29 @@ class EnableEditingMaterialSetItem(bpy.types.Operator): return {"FINISHED"} + def import_attributes_callback( + self, name: str, prop: Union["Attribute", None], data: dict[str, Any] + ) -> None | Literal[True]: + if data["type"] != "IfcMaterialLayer" or name != "IsVentilated" or not prop: + return None + + # Keep null semantics unchanged on export, but avoid an empty UI selection. + prop.data_type = "enum" + prop.special_type = "LOGICAL" + prop.enum_items = json.dumps(("TRUE", "FALSE", "UNKNOWN")) + + value = data[name] + if value == "UNKNOWN": + prop.enum_value = "UNKNOWN" + elif value is None: + # Keep visible default as FALSE, but preserve null semantics on save. + prop.enum_value = "FALSE" + prop.is_null = True + else: + prop.enum_value = "TRUE" if value else "FALSE" + + return True + class DisableEditingMaterialSetItem(bpy.types.Operator): bl_idname = "bim.disable_editing_material_set_item" diff --git a/src/bonsai/test/bim/feature/material.feature b/src/bonsai/test/bim/feature/material.feature index 2f34cbb893..67525cd8f3 100644 --- a/src/bonsai/test/bim/feature/material.feature +++ b/src/bonsai/test/bim/feature/material.feature @@ -422,6 +422,24 @@ Scenario: Enable editing material set item When I press "bim.enable_editing_material_set_item(material_set_item={material_profile})" Then nothing happens +Scenario: Edit layer item defaults null IsVentilated to FALSE in UI + 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 I press "bim.add_material()" + And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet" + And I press "bim.assign_material" + And I press "bim.enable_editing_assigned_material" + And the variable "layer" is "{ifc}.by_type('IfcMaterialLayer')[0].id()" + And I press "bim.enable_editing_material_set_item(material_set_item={layer})" + When I evaluate expression "attrs = bpy.context.active_object.BIMObjectMaterialProperties.material_set_item_attributes; is_vent = next(a for a in attrs if a.name == 'IsVentilated'); assert is_vent.enum_value == 'FALSE'; assert is_vent.is_null is True" + And I press "bim.edit_material_set_item(material_set_item={layer})" + Then I evaluate expression "assert {ifc}.by_id({layer}).IsVentilated is None" + Scenario: Add material set layer Given an empty IFC project And I add a cube From bcfad8d96d7cf93819af6d8f6f62a93efc6425fb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 21 Mar 2026 15:56:12 +1100 Subject: [PATCH 68/76] Migrate remove_deep to remove_deep2 across API modules remove_deep is deprecated and can silently delete elements still in use. remove_deep2 requires zero inverses before removal, making it safer. Also fixes a double-removal bug in remove_grid_axis and prevents removing the last prop template from a pset template. Co-Authored-By: Claude Opus 4.6 --- .../api/context/remove_context.py | 4 +- .../ifcopenshell/api/cost/edit_cost_value.py | 4 +- .../ifcopenshell/api/grid/remove_grid_axis.py | 4 +- .../api/pset_template/remove_prop_template.py | 12 ++-- .../api/pset_template/remove_pset_template.py | 2 +- .../api/resource/add_resource_quantity.py | 2 +- .../api/resource/remove_resource_quantity.py | 2 +- .../ifcopenshell/api/unit/remove_unit.py | 3 +- .../test/api/cost/test_edit_cost_value.py | 72 +++++++++++++++++++ .../test/api/grid/test_remove_grid_axis.py | 59 +++++++++++++++ .../test_remove_prop_template.py | 38 ++++++++++ .../test_remove_pset_template.py | 35 +++++++++ .../resource/test_remove_resource_quantity.py | 44 ++++++++++++ 13 files changed, 264 insertions(+), 17 deletions(-) create mode 100644 src/ifcopenshell-python/test/api/cost/test_edit_cost_value.py create mode 100644 src/ifcopenshell-python/test/api/grid/test_remove_grid_axis.py create mode 100644 src/ifcopenshell-python/test/api/pset_template/test_remove_prop_template.py create mode 100644 src/ifcopenshell-python/test/api/pset_template/test_remove_pset_template.py create mode 100644 src/ifcopenshell-python/test/api/resource/test_remove_resource_quantity.py diff --git a/src/ifcopenshell-python/ifcopenshell/api/context/remove_context.py b/src/ifcopenshell-python/ifcopenshell/api/context/remove_context.py index bb4dcff79a..806782b1e4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/context/remove_context.py +++ b/src/ifcopenshell-python/ifcopenshell/api/context/remove_context.py @@ -51,8 +51,10 @@ def remove_context(file: ifcopenshell.file, context: ifcopenshell.entity_instanc new = context.ParentContext for inverse in file.get_inverse(context): if inverse.is_a("IfcCoordinateOperation"): + # Trick to make sure the coordinate operation is not referenced + # by a context so we can delete it safely inverse.SourceCRS = inverse.TargetCRS - ifcopenshell.util.element.remove_deep(file, inverse) + ifcopenshell.util.element.remove_deep2(file, inverse) else: ifcopenshell.util.element.replace_attribute(inverse, context, new) file.remove(context) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_value.py b/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_value.py index e1be553922..3a9bbe0cde 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_value.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_value.py @@ -59,6 +59,6 @@ def edit_cost_value( value["ValueComponent"], ) value = file.create_entity("IfcMeasureWithUnit", value_component, value["UnitComponent"]) - if old_unit_basis and file.get_total_inverses(old_unit_basis) == 0: - ifcopenshell.util.element.remove_deep(file, old_unit_basis) + if old_unit_basis: + ifcopenshell.util.element.remove_deep2(file, old_unit_basis) setattr(cost_value, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/grid/remove_grid_axis.py b/src/ifcopenshell-python/ifcopenshell/api/grid/remove_grid_axis.py index 652fc89f56..f9bcacba8d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/grid/remove_grid_axis.py +++ b/src/ifcopenshell-python/ifcopenshell/api/grid/remove_grid_axis.py @@ -42,7 +42,5 @@ def remove_grid_axis(file: ifcopenshell.file, axis: ifcopenshell.entity_instance ifcopenshell.api.grid.remove_grid_axis(model, axis=axis_2) """ axis_curve = axis.AxisCurve - if file.get_total_inverses(axis_curve) == 1: - ifcopenshell.util.element.remove_deep(file, axis_curve) - file.remove(axis_curve) file.remove(axis) + ifcopenshell.util.element.remove_deep2(file, axis_curve) diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_prop_template.py b/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_prop_template.py index ca8d5ba55e..2f81494bc7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_prop_template.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_prop_template.py @@ -22,9 +22,9 @@ import ifcopenshell.util.element def remove_prop_template(file: ifcopenshell.file, prop_template: ifcopenshell.entity_instance) -> None: """Removes a property template - Note that a property set template should always have at least one - property template to be valid, so take care when removing property - templates. + Note that a property set template should always have at least one property + template to be valid. So a property set template will not be removed if it + is the only template ina a property ste template. :param prop_template: The IfcSimplePropertyTemplate to remove. :return: None @@ -43,10 +43,8 @@ def remove_prop_template(file: ifcopenshell.file, prop_template: ifcopenshell.en ifcopenshell.api.pset_template.remove_prop_template(model, prop_template=prop2) """ for inverse in file.get_inverse(prop_template): - if len(inverse.HasPropertyTemplates) == 1: - inverse.HasPropertyTemplates = [] - else: + if len(inverse.HasPropertyTemplates) > 1: has_property_templates = list(inverse.HasPropertyTemplates) has_property_templates.remove(prop_template) inverse.HasPropertyTemplates = has_property_templates - ifcopenshell.util.element.remove_deep(file, prop_template) + ifcopenshell.util.element.remove_deep2(file, prop_template) diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_pset_template.py b/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_pset_template.py index 07d5cf6daf..5ff3cf6c38 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_pset_template.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_pset_template.py @@ -38,4 +38,4 @@ def remove_pset_template(file: ifcopenshell.file, pset_template: ifcopenshell.en # Let's remove the template. ifcopenshell.api.pset_template.remove_pset_template(model, pset_template=template) """ - ifcopenshell.util.element.remove_deep(file, pset_template) + ifcopenshell.util.element.remove_deep2(file, pset_template) diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource_quantity.py index fc67a8d3c7..ceb49681b5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource_quantity.py @@ -79,5 +79,5 @@ def add_resource_quantity( old_quantity = resource.BaseQuantity resource.BaseQuantity = quantity if old_quantity: - ifcopenshell.util.element.remove_deep(file, old_quantity) + ifcopenshell.util.element.remove_deep2(file, old_quantity) return quantity diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/remove_resource_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/resource/remove_resource_quantity.py index a6b014e1db..afb470565d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/remove_resource_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/remove_resource_quantity.py @@ -47,4 +47,4 @@ def remove_resource_quantity(file: ifcopenshell.file, resource: ifcopenshell.ent old_quantity = resource.BaseQuantity resource.BaseQuantity = None if old_quantity: - ifcopenshell.util.element.remove_deep(file, old_quantity) + ifcopenshell.util.element.remove_deep2(file, old_quantity) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/remove_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/remove_unit.py index 36ab2f73a2..2611df0b4e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/remove_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/remove_unit.py @@ -47,4 +47,5 @@ def remove_unit(file: ifcopenshell.file, unit: ifcopenshell.entity_instance) -> unit_assignment.Units = units else: file.remove(unit_assignment) - ifcopenshell.util.element.remove_deep(file, unit) + # TODO handle other possible unit inverses + ifcopenshell.util.element.remove_deep2(file, unit) diff --git a/src/ifcopenshell-python/test/api/cost/test_edit_cost_value.py b/src/ifcopenshell-python/test/api/cost/test_edit_cost_value.py new file mode 100644 index 0000000000..c8935c8e9b --- /dev/null +++ b/src/ifcopenshell-python/test/api/cost/test_edit_cost_value.py @@ -0,0 +1,72 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.cost +import ifcopenshell.api.unit +import test.bootstrap + + +class TestEditCostValue(test.bootstrap.IFC4): + def test_editing_applied_value(self): + schedule = ifcopenshell.api.cost.add_cost_schedule(self.file) + item = ifcopenshell.api.cost.add_cost_item(self.file, cost_schedule=schedule) + value = ifcopenshell.api.cost.add_cost_value(self.file, parent=item) + ifcopenshell.api.cost.edit_cost_value(self.file, cost_value=value, attributes={"AppliedValue": 42.0}) + assert value.AppliedValue.wrappedValue == 42.0 + + def test_editing_unit_basis_removes_old_deeply(self): + schedule = ifcopenshell.api.cost.add_cost_schedule(self.file) + item = ifcopenshell.api.cost.add_cost_item(self.file, cost_schedule=schedule) + value = ifcopenshell.api.cost.add_cost_value(self.file, parent=item) + unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") + ifcopenshell.api.cost.edit_cost_value( + self.file, + cost_value=value, + attributes={"UnitBasis": {"ValueComponent": 1.0, "UnitComponent": unit}}, + ) + old_basis = value.UnitBasis + assert old_basis is not None + old_basis_id = old_basis.id() + # Now change to a new unit basis — the old one should be deeply removed. + ifcopenshell.api.cost.edit_cost_value( + self.file, + cost_value=value, + attributes={"UnitBasis": {"ValueComponent": 2.0, "UnitComponent": unit}}, + ) + assert value.UnitBasis is not None + assert value.UnitBasis.id() != old_basis_id + + def test_clearing_unit_basis(self): + schedule = ifcopenshell.api.cost.add_cost_schedule(self.file) + item = ifcopenshell.api.cost.add_cost_item(self.file, cost_schedule=schedule) + value = ifcopenshell.api.cost.add_cost_value(self.file, parent=item) + unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") + ifcopenshell.api.cost.edit_cost_value( + self.file, + cost_value=value, + attributes={"UnitBasis": {"ValueComponent": 1.0, "UnitComponent": unit}}, + ) + assert value.UnitBasis is not None + ifcopenshell.api.cost.edit_cost_value( + self.file, cost_value=value, attributes={"UnitBasis": None} + ) + assert value.UnitBasis is None + + +class TestEditCostValueIFC4X3(test.bootstrap.IFC4X3, TestEditCostValue): + pass diff --git a/src/ifcopenshell-python/test/api/grid/test_remove_grid_axis.py b/src/ifcopenshell-python/test/api/grid/test_remove_grid_axis.py new file mode 100644 index 0000000000..f59930e0e5 --- /dev/null +++ b/src/ifcopenshell-python/test/api/grid/test_remove_grid_axis.py @@ -0,0 +1,59 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.grid +import test.bootstrap + + +class TestRemoveGridAxis(test.bootstrap.IFC4): + def test_removing_an_axis_removes_its_curve(self): + grid = self.file.createIfcGrid() + axis = ifcopenshell.api.grid.create_grid_axis( + self.file, axis_tag="A", same_sense=True, uvw_axes="UAxes", grid=grid + ) + axis.AxisCurve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))]) + axis2 = ifcopenshell.api.grid.create_grid_axis( + self.file, axis_tag="B", same_sense=True, uvw_axes="UAxes", grid=grid + ) + axis2.AxisCurve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint((1.0, 0.0, 0.0))]) + ifcopenshell.api.grid.remove_grid_axis(self.file, axis=axis2) + assert grid.UAxes == (axis,) + assert len(self.file.by_type("IfcGridAxis")) == 1 + # The curve should be removed since it was only used by the removed axis. + assert len(self.file.by_type("IfcPolyline")) == 1 + + def test_removing_an_axis_preserves_shared_curve(self): + grid = self.file.createIfcGrid() + shared_curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))]) + axis = ifcopenshell.api.grid.create_grid_axis( + self.file, axis_tag="A", same_sense=True, uvw_axes="UAxes", grid=grid + ) + axis.AxisCurve = shared_curve + axis2 = ifcopenshell.api.grid.create_grid_axis( + self.file, axis_tag="B", same_sense=True, uvw_axes="UAxes", grid=grid + ) + axis2.AxisCurve = shared_curve + ifcopenshell.api.grid.remove_grid_axis(self.file, axis=axis2) + assert grid.UAxes == (axis,) + # The shared curve should be preserved since it's still used by axis. + assert shared_curve in self.file + assert axis.AxisCurve == shared_curve + + +class TestRemoveGridAxisIFC2X3(test.bootstrap.IFC2X3, TestRemoveGridAxis): + pass diff --git a/src/ifcopenshell-python/test/api/pset_template/test_remove_prop_template.py b/src/ifcopenshell-python/test/api/pset_template/test_remove_prop_template.py new file mode 100644 index 0000000000..d967a0017a --- /dev/null +++ b/src/ifcopenshell-python/test/api/pset_template/test_remove_prop_template.py @@ -0,0 +1,38 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.pset_template +import test.bootstrap + + +class TestRemovePropTemplate(test.bootstrap.IFC4): + def test_removing_a_prop_template(self): + template = ifcopenshell.api.pset_template.add_pset_template(self.file, name="ABC_RiskFactors") + prop1 = ifcopenshell.api.pset_template.add_prop_template(self.file, pset_template=template) + prop2 = ifcopenshell.api.pset_template.add_prop_template(self.file, pset_template=template) + ifcopenshell.api.pset_template.remove_prop_template(self.file, prop_template=prop2) + assert len(self.file.by_type("IfcSimplePropertyTemplate")) == 1 + assert template.HasPropertyTemplates == (prop1,) + + def test_not_removing_the_last_prop_template(self): + template = ifcopenshell.api.pset_template.add_pset_template(self.file, name="ABC_RiskFactors") + prop = ifcopenshell.api.pset_template.add_prop_template(self.file, pset_template=template) + ifcopenshell.api.pset_template.remove_prop_template(self.file, prop_template=prop) + # The last prop template should not be removed to keep the pset template valid. + assert len(self.file.by_type("IfcSimplePropertyTemplate")) == 1 + assert template.HasPropertyTemplates == (prop,) diff --git a/src/ifcopenshell-python/test/api/pset_template/test_remove_pset_template.py b/src/ifcopenshell-python/test/api/pset_template/test_remove_pset_template.py new file mode 100644 index 0000000000..67c700a74f --- /dev/null +++ b/src/ifcopenshell-python/test/api/pset_template/test_remove_pset_template.py @@ -0,0 +1,35 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.pset_template +import test.bootstrap + + +class TestRemovePsetTemplate(test.bootstrap.IFC4): + def test_removing_a_pset_template(self): + template = ifcopenshell.api.pset_template.add_pset_template(self.file, name="ABC_RiskFactors") + ifcopenshell.api.pset_template.remove_pset_template(self.file, pset_template=template) + assert len(self.file.by_type("IfcPropertySetTemplate")) == 0 + + def test_removing_a_pset_template_with_property_templates(self): + template = ifcopenshell.api.pset_template.add_pset_template(self.file, name="ABC_RiskFactors") + prop1 = ifcopenshell.api.pset_template.add_prop_template(self.file, pset_template=template) + prop2 = ifcopenshell.api.pset_template.add_prop_template(self.file, pset_template=template) + ifcopenshell.api.pset_template.remove_pset_template(self.file, pset_template=template) + assert len(self.file.by_type("IfcPropertySetTemplate")) == 0 + assert len(self.file.by_type("IfcSimplePropertyTemplate")) == 0 diff --git a/src/ifcopenshell-python/test/api/resource/test_remove_resource_quantity.py b/src/ifcopenshell-python/test/api/resource/test_remove_resource_quantity.py new file mode 100644 index 0000000000..c91e2f27c6 --- /dev/null +++ b/src/ifcopenshell-python/test/api/resource/test_remove_resource_quantity.py @@ -0,0 +1,44 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.resource +import test.bootstrap + + +class TestRemoveResourceQuantity(test.bootstrap.IFC4): + def test_removing_a_resource_quantity(self): + self.file.create_entity("IfcProject") + resource = ifcopenshell.api.resource.add_resource(self.file, ifc_class="IfcLaborResource") + ifcopenshell.api.resource.add_resource_quantity( + self.file, resource=resource, ifc_class="IfcQuantityTime" + ) + assert resource.BaseQuantity is not None + ifcopenshell.api.resource.remove_resource_quantity(self.file, resource=resource) + assert resource.BaseQuantity is None + assert len(self.file.by_type("IfcPhysicalSimpleQuantity")) == 0 + + def test_removing_a_resource_quantity_when_none_exists(self): + self.file.create_entity("IfcProject") + resource = ifcopenshell.api.resource.add_resource(self.file, ifc_class="IfcLaborResource") + # Should not raise. + ifcopenshell.api.resource.remove_resource_quantity(self.file, resource=resource) + assert resource.BaseQuantity is None + + +class TestRemoveResourceQuantityIFC2X3(test.bootstrap.IFC2X3, TestRemoveResourceQuantity): + pass From fca258fb0761ade0ebd1038cc3097ee30e0f2b37 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 21 Mar 2026 18:21:41 +1100 Subject: [PATCH 69/76] Fix add_boolean removing second operands from unrelated representations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_boolean was removing second operands from ALL IfcShapeRepresentations that referenced them, which could corrupt unrelated shapes and leave representations with empty Items (bug #7803). The API no longer modifies Items — callers manage this explicitly. validate_type and Bonsai's AddBoolean operator now handle their own item removal scoped to the correct representation. Co-Authored-By: Claude Opus 4.6 --- src/bonsai/bonsai/bim/module/model/opening.py | 10 +++++ .../ifcopenshell/api/geometry/add_boolean.py | 14 ------ .../api/geometry/validate_type.py | 1 + .../test/api/geometry/test_add_boolean.py | 44 ++++++++++--------- .../test/api/geometry/test_validate_type.py | 6 +-- 5 files changed, 38 insertions(+), 37 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index e680ceeb54..1c157afe4e 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -540,6 +540,16 @@ class AddBoolean(Operator, tool.Ifc.Operator): booleans = ifcopenshell.api.geometry.add_boolean(tool.Ifc.get(), first_item, second_items, props.operator) rep_obj = tool.Geometry.get_geometry_props().representation_obj + if booleans: + # Users typically select two top-level items and expect the + # operand to be absorbed into the boolean, not remain as a + # standalone item alongside it. + representation = tool.Geometry.get_active_representation(rep_obj) + representation = ifcopenshell.util.representation.resolve_representation(representation) + second_items_set = set(second_items) + new_items = [i for i in representation.Items if i not in second_items_set] + if new_items: + representation.Items = new_items rep_element = tool.Ifc.get_entity(rep_obj) tool.Model.mark_manual_booleans(rep_element, booleans) tool.Geometry.reload_representation(rep_obj) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py index 910df42d8c..80ebf5bb89 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py @@ -31,17 +31,6 @@ def add_boolean( ) -> list[ifcopenshell.entity_instance]: """Adds a boolean operation to two or more representation items - If an IfcBooleanOperand is part of the top level items in an - IfcShapeRepresentation, it will be removed from that level whilst being - added to the IfcBooleanResult. This is because it is generally intuitive - that an item is either participating in a boolean operation, or being an - item in its own right, but not both. - - However, if an IfcBooleanOperand is part of another boolean operation - already, it will not be removed from the existing operation. A new - operation will be created, and therefore it will participate in two - operations. - This function protects against recursive booleans. After a boolean operation is made, since the items of @@ -101,9 +90,6 @@ def add_boolean( booleans = [] for second_item in second_items: - for inverse in file.get_inverse(second_item): - if inverse.is_a("IfcShapeRepresentation"): - inverse.Items = list(set(inverse.Items) - {second_item}) if first.is_a("IfcTesselatedFaceSet"): first.Closed = True # For now, trust the user to do the right thing. if second_item.is_a("IfcTesselatedFaceSet"): diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py index 46bec8757b..3731b2fffc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py @@ -83,6 +83,7 @@ def validate_type( if remaining_items: ifcopenshell.api.geometry.add_boolean(file, preferred_item, remaining_items, "UNION") + representation.Items = [i for i in representation.Items if i not in remaining_items] representation.RepresentationType = ifcopenshell.util.representation.guess_type(representation.Items) if representation.RepresentationType == "CSG": diff --git a/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py b/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py index d507f89308..f750795ee7 100644 --- a/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py +++ b/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py @@ -42,7 +42,7 @@ class TestAddBoolean(test.bootstrap.IFC4): assert boolean.FirstOperand == first assert boolean.SecondOperand == second assert boolean.Operator == "DIFFERENCE" - assert set(rep.Items) == {boolean} + assert set(rep.Items) == {boolean, second} def test_adding_multiple_booleans_from_three_top_level_items(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") @@ -58,13 +58,14 @@ class TestAddBoolean(test.bootstrap.IFC4): booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second1, second2]) assert len(booleans) == 2 - assert len(rep.Items) == 1 - assert rep.Items[0].FirstOperand.is_a("IfcBooleanResult") - assert rep.Items[0].SecondOperand == second2 - assert rep.Items[0].Operator == "DIFFERENCE" - assert rep.Items[0].FirstOperand.FirstOperand == first - assert rep.Items[0].FirstOperand.SecondOperand == second1 - assert rep.Items[0].FirstOperand.Operator == "DIFFERENCE" + final_boolean = booleans[-1] + assert final_boolean.FirstOperand.is_a("IfcBooleanResult") + assert final_boolean.SecondOperand == second2 + assert final_boolean.Operator == "DIFFERENCE" + assert final_boolean.FirstOperand.FirstOperand == first + assert final_boolean.FirstOperand.SecondOperand == second1 + assert final_boolean.FirstOperand.Operator == "DIFFERENCE" + assert set(rep.Items) == {final_boolean, second1, second2} def test_adding_a_boolean_to_an_existing_operand_from_a_top_level_item(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") @@ -78,14 +79,16 @@ class TestAddBoolean(test.bootstrap.IFC4): second2 = builder.block() rep = builder.get_representation(body, [first, second1]) booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second1]) + # second1 stays in Items, add second2 as well rep.Items = list(rep.Items) + [second2] booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second2]) assert len(booleans) == 1 - assert len(rep.Items) == 1 - assert rep.Items[0].FirstOperand.is_a("IfcBooleanResult") - assert rep.Items[0].SecondOperand == second2 - assert rep.Items[0].FirstOperand.FirstOperand == first - assert rep.Items[0].FirstOperand.SecondOperand == second1 + final_boolean = booleans[0] + assert final_boolean.FirstOperand.is_a("IfcBooleanResult") + assert final_boolean.SecondOperand == second2 + assert final_boolean.FirstOperand.FirstOperand == first + assert final_boolean.FirstOperand.SecondOperand == second1 + assert set(rep.Items) == {final_boolean, second1, second2} def test_adding_a_boolean_to_an_existing_operand_from_another_operand(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") @@ -104,7 +107,7 @@ class TestAddBoolean(test.bootstrap.IFC4): booleans = ifcopenshell.api.geometry.add_boolean(self.file, first1, [second2]) assert len(booleans) == 1 - assert len(rep.Items) == 2 + assert len(rep.Items) == 4 assert self.file.get_total_inverses(first1) == 1 result = next(iter(self.file.get_inverse(first1))) @@ -132,14 +135,15 @@ class TestAddBoolean(test.bootstrap.IFC4): rep = builder.get_representation(body, [first, second]) ifcopenshell.api.geometry.add_boolean(self.file, first, [second]) ifcopenshell.api.geometry.add_boolean(self.file, first, [second]) - assert len(rep.Items) == 1 - assert rep.Items[0].FirstOperand == first - assert rep.Items[0].SecondOperand == second + assert set(rep.Items) == {self.file.by_type("IfcBooleanResult")[0], second} + boolean = self.file.by_type("IfcBooleanResult")[0] + assert boolean.FirstOperand == first + assert boolean.SecondOperand == second ifcopenshell.api.geometry.add_boolean(self.file, second, [second]) ifcopenshell.api.geometry.add_boolean(self.file, second, [first]) - assert len(rep.Items) == 1 - assert rep.Items[0].FirstOperand == first - assert rep.Items[0].SecondOperand == second + assert set(rep.Items) == {boolean, second} + assert boolean.FirstOperand == first + assert boolean.SecondOperand == second assert len(self.file.by_type("IfcBooleanResult")) == 1 diff --git a/src/ifcopenshell-python/test/api/geometry/test_validate_type.py b/src/ifcopenshell-python/test/api/geometry/test_validate_type.py index 7f4a57e995..01fa17bfed 100644 --- a/src/ifcopenshell-python/test/api/geometry/test_validate_type.py +++ b/src/ifcopenshell-python/test/api/geometry/test_validate_type.py @@ -76,7 +76,7 @@ class TestValidateType(test.bootstrap.IFC4): booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second1]) assert len(booleans) == 1 - assert len(rep.Items) == 3 + assert len(rep.Items) == 4 assert ifcopenshell.api.geometry.validate_type(self.file, rep) is True assert len(rep.Items) == 1 assert rep.RepresentationType == "CSG" @@ -96,9 +96,9 @@ class TestValidateType(test.bootstrap.IFC4): booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second1]) assert len(booleans) == 1 - assert len(rep.Items) == 2 + assert len(rep.Items) == 3 # boolean replaced first, but second1 stays in Items assert ifcopenshell.api.geometry.validate_type(self.file, rep) is False - assert len(rep.Items) == 2 + assert len(rep.Items) == 2 # validate_type unioned second1 into the boolean assert rep.RepresentationType is None From 94c15213f661564d49a9385e0f6802c9faabb8dd Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 21 Mar 2026 20:09:47 +1100 Subject: [PATCH 70/76] Guard against emptying IfcShapeRepresentation Items remove_representation_item now returns early if removing the item would leave Items empty. edit_text_literals returns early on empty attributes. Co-Authored-By: Claude Opus 4.6 --- src/bonsai/bonsai/tool/drawing.py | 2 ++ src/bonsai/bonsai/tool/geometry.py | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index accaaad10d..86113b9c95 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -857,6 +857,8 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None: + if not literal_attributes: + return assert (element := tool.Ifc.get_entity(obj)) assert (rep := cls.get_annotation_representation(element)) to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")] diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index e24152d642..0d690d0308 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -1407,8 +1407,6 @@ class Geometry(bonsai.core.tool.Geometry): :param representation_item: item to remove. :param element: item's element. Is used to unmark manual booleans. """ - # NOTE: we assume it's not the last representation item - # otherwise we probably would need to remove representation too # NOTE: a lot of shared code with `geometry.remove_representation` ifc_file = tool.Ifc.get() shape_aspects: list[ifcopenshell.entity_instance] = [] @@ -1467,7 +1465,10 @@ class Geometry(bonsai.core.tool.Geometry): cls.remove_representation_items_from_shape_aspect([representation_item], shape_aspect) if representation: - representation.Items = tuple(set(representation.Items) - {representation_item}) + new_items = tuple(set(representation.Items) - {representation_item}) + if not new_items: + return + representation.Items = new_items also_consider = list(consider_inverses) ifcopenshell.util.element.remove_deep2(ifc_file, representation_item, also_consider=also_consider) From 547b22199f50d5d1b0bca5305ea36a31fde199e6 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 21 Mar 2026 18:02:02 -0500 Subject: [PATCH 71/76] Without 'Material.Name' layers merge. (#7700) --- src/bonsai/bonsai/bim/module/drawing/operator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 58474d8ce5..45f67b0769 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1426,6 +1426,7 @@ class CreateDrawing(bpy.types.Operator): "/Pset_.*Common/.Status", "EPset_Status.Status", "EPset_Status.UserDefinedStatus", + "Material.Name", ] group = root.find("{http://www.w3.org/2000/svg}g") From 41469acbc8642868ee430945c7b55e0ae4145e9e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 22 Mar 2026 13:58:10 +1100 Subject: [PATCH 72/76] Fix walrus operator precedence in MaterialCreator The `is not ...` was being captured by the walrus assignment due to missing parentheses, causing the condition to always evaluate incorrectly. Co-Authored-By: Claude Opus 4.6 --- src/bonsai/bonsai/bim/import_ifc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 11b92be532..d6758de903 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -64,8 +64,8 @@ class MaterialCreator: mesh: Union[OBJECT_DATA_TYPE, None], shape_has_openings: bool, ) -> None: - if ((rep := getattr(element, "Representation", ...) is not ...) and not rep) or ( - (rep := getattr(element, "RepresentationMaps", ...) is not ...) and not rep + if (((rep := getattr(element, "Representation", ...)) is not ... and not rep) or + ((rep := getattr(element, "RepresentationMaps", ...)) is not ... and not rep) ): return From 1771b344496f9406d6d1a37818ab27dca07bcc5f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 22 Mar 2026 14:08:06 +1100 Subject: [PATCH 73/76] Fix error when entering edit mode on camera objects Fixes #7313. Co-Authored-By: Claude Opus 4.6 --- src/bonsai/bonsai/bim/module/geometry/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index d5f159abe3..7382b093f4 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -2289,7 +2289,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): elif obj in pprops.clipping_planes_objs: self.report({"ERROR"}, "Clipping planes cannot be edited") elif element: - if not obj.data: + if not obj.data or obj.type not in ("MESH", "CURVE"): self.report({"INFO"}, "No geometry to edit") elif tool.Geometry.is_locked(element): self.report({"ERROR"}, lock_error_message(obj.name)) From ecde429d36d7387224b036390c1692a056429835 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 22 Mar 2026 13:53:56 +1100 Subject: [PATCH 74/76] Fix crash after undo of assign_class on macOS (#7419) After assigning an IFC class and undoing, msgbus subscriptions registered with the old Python object wrapper survived (PERSISTENT flag) but could not be cleared because: (1) rollback_link_element looked up objects by their post-link name which no longer exists after undo, and (2) the per-object clear_by_owner calls in rebuild_element_maps used new Python wrappers that didn't match the old subscription owners. Fix by using a dedicated stable object (object_subscription_owner) as the msgbus owner for all per-object subscriptions, allowing rebuild_element_maps to clear all stale subscriptions in one call regardless of Python wrapper identity changes during undo/redo. Co-Authored-By: Claude Opus 4.6 --- src/bonsai/bonsai/bim/handler.py | 13 ++++++++----- src/bonsai/bonsai/bim/ifc.py | 13 ++++--------- src/bonsai/bonsai/tool/ifc.py | 10 ++++++---- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index 231b44c671..e11eb07ce8 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -45,16 +45,19 @@ from bonsai.bim.module.nest.decorator import NestDecorator cwd = os.path.dirname(os.path.realpath(__file__)) global_subscription_owner = object() +# Separate owner for per-object msgbus subscriptions (name, active_material_index). +# Using a dedicated owner allows clearing all per-object subscriptions at once +# during undo/redo without affecting other global subscriptions. +object_subscription_owner = object() def name_callback(obj: Union[bpy.types.Object, bpy.types.Material], data: str) -> None: try: obj.name except: - # The object is invalid but somehow still has a callback. Clear all - # msgbus subscriptions to prevent useless further triggers. - bpy.msgbus.clear_by_owner(obj) - return # In case the object RNA is gone during an undo / redo operation + # The object is invalid but somehow still has a callback. + # This can occur during undo/redo when the Python wrapper is stale. + return # Blender names are up to 63 UTF-8 bytes if len(bytes(obj.name, "utf-8")) >= 63: return @@ -189,7 +192,7 @@ def subscribe_to(obj: bpy.types.ID, data_path: str, callback: Callable[[bpy.type return bpy.msgbus.subscribe_rna( key=subscribe_to, - owner=obj, + owner=object_subscription_owner, args=( obj, data_path, diff --git a/src/bonsai/bonsai/bim/ifc.py b/src/bonsai/bonsai/bim/ifc.py index f7d23e1dbe..b07e584a71 100644 --- a/src/bonsai/bonsai/bim/ifc.py +++ b/src/bonsai/bonsai/bim/ifc.py @@ -316,11 +316,8 @@ class IfcStore: del IfcStore.id_map[data["id"]] if "guid" in data: del IfcStore.guid_map[data["guid"]] - obj = IfcStore.get_object_by_name(data["obj"]) - if obj is None: - # obj was just created during this step and didn't existed before. - return - bpy.msgbus.clear_by_owner(obj) + # Note: msgbus subscriptions are cleared globally during + # rebuild_element_maps which runs after every undo/redo. @staticmethod def commit_link_element(data: OperationData) -> None: @@ -367,10 +364,8 @@ class IfcStore: del IfcStore.id_map[data["id"]] if "guid" in data: del IfcStore.guid_map[data["guid"]] - obj = IfcStore.get_object_by_name(data["obj"]) - # obj might be removed after unlink. - if not obj: - bpy.msgbus.clear_by_owner(obj) + # Note: msgbus subscriptions are cleared globally during + # rebuild_element_maps which runs after every undo/redo. @staticmethod def unlink_element( diff --git a/src/bonsai/bonsai/tool/ifc.py b/src/bonsai/bonsai/tool/ifc.py index 8cbd2a112b..6d79ce7582 100644 --- a/src/bonsai/bonsai/tool/ifc.py +++ b/src/bonsai/bonsai/tool/ifc.py @@ -197,12 +197,16 @@ class Ifc(bonsai.core.tool.Ifc): if not cls.get(): return + # Clear all per-object msgbus subscriptions at once using the dedicated + # owner. After undo/redo, per-object Python wrappers have new + # identities so clearing by individual obj would miss stale + # subscriptions registered with the old wrappers. + bpy.msgbus.clear_by_owner(bonsai.bim.handler.object_subscription_owner) + for obj in bpy.data.objects: if obj.library: continue - bpy.msgbus.clear_by_owner(obj) - element = cls.get_entity(obj) if not element: continue @@ -217,8 +221,6 @@ class Ifc(bonsai.core.tool.Ifc): if obj.library: continue - bpy.msgbus.clear_by_owner(obj) - style = cls.get_entity(obj) if not style: continue From b8136d47621c911a230ff457c89189229c5c34fc Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 22 Mar 2026 15:06:10 +1100 Subject: [PATCH 75/76] Prevent cyclic references when assigning nesting or aggregation Walk up the full hierarchy via get_parent() in can_nest() and can_aggregate() to reject assignments that would create a cycle. Also reject self-assignment. Fix #7248 Co-Authored-By: Claude Opus 4.6 --- src/bonsai/bonsai/tool/aggregate.py | 37 +++++++++++++++++++------- src/bonsai/bonsai/tool/nest.py | 20 +++++++++++--- src/bonsai/test/tool/test_aggregate.py | 37 ++++++++++++++++++++++++++ src/bonsai/test/tool/test_nest.py | 37 ++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 12 deletions(-) diff --git a/src/bonsai/bonsai/tool/aggregate.py b/src/bonsai/bonsai/tool/aggregate.py index 43b155a5aa..1f7f601862 100644 --- a/src/bonsai/bonsai/tool/aggregate.py +++ b/src/bonsai/bonsai/tool/aggregate.py @@ -49,21 +49,40 @@ class Aggregate(bonsai.core.tool.Aggregate): related_object = tool.Ifc.get_entity(related_obj) if not relating_object or not related_object: return False + if relating_object == related_object: + return False + + is_compatible_class = False if (relating_object.is_a("IfcElement") or relating_object.is_a("IfcElementType")) and related_object.is_a( "IfcElement" ): - return True - if tool.Ifc.get_schema() == "IFC2X3": + is_compatible_class = True + elif tool.Ifc.get_schema() == "IFC2X3": if relating_object.is_a("IfcSpatialStructureElement") and related_object.is_a("IfcSpatialStructureElement"): - return True - if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"): - return True + is_compatible_class = True + elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"): + is_compatible_class = True else: if relating_object.is_a("IfcSpatialElement") and related_object.is_a("IfcSpatialElement"): - return True - if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"): - return True - return False + is_compatible_class = True + elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"): + is_compatible_class = True + + if not is_compatible_class: + return False + + # Prevent cyclic references: walk up the full hierarchy from the + # proposed parent and reject if we encounter the proposed child. + ancestor = ifcopenshell.util.element.get_parent(relating_object) + seen = {relating_object} + while ancestor: + if ancestor == related_object: + return False + if ancestor in seen: + break + seen.add(ancestor) + ancestor = ifcopenshell.util.element.get_parent(ancestor) + return True @classmethod def has_physical_body_representation(cls, element: ifcopenshell.entity_instance) -> bool: diff --git a/src/bonsai/bonsai/tool/nest.py b/src/bonsai/bonsai/tool/nest.py index 37d0fa678f..5a1b2c83b2 100644 --- a/src/bonsai/bonsai/tool/nest.py +++ b/src/bonsai/bonsai/tool/nest.py @@ -45,9 +45,23 @@ class Nest(bonsai.core.tool.Nest): related_object = tool.Ifc.get_entity(related_obj) if not relating_object or not related_object: return False - if relating_object.is_a("IfcElement") and related_object.is_a("IfcElement"): - return True - return False + if relating_object == related_object: + return False + is_compatible_class = relating_object.is_a("IfcElement") and related_object.is_a("IfcElement") + if not is_compatible_class: + return False + # Prevent cyclic references: walk up the full hierarchy from the + # proposed parent and reject if we encounter the proposed child. + ancestor = ifcopenshell.util.element.get_parent(relating_object) + seen = {relating_object} + while ancestor: + if ancestor == related_object: + return False + if ancestor in seen: + break + seen.add(ancestor) + ancestor = ifcopenshell.util.element.get_parent(ancestor) + return True @classmethod def disable_editing(cls, obj: bpy.types.Object) -> None: diff --git a/src/bonsai/test/tool/test_aggregate.py b/src/bonsai/test/tool/test_aggregate.py index bab71b2c4a..4fd9388c13 100644 --- a/src/bonsai/test/tool/test_aggregate.py +++ b/src/bonsai/test/tool/test_aggregate.py @@ -18,6 +18,7 @@ import bpy import ifcopenshell +import ifcopenshell.api.aggregate import ifcopenshell.api.context import ifcopenshell.api.geometry import ifcopenshell.api.root @@ -99,6 +100,42 @@ class TestCanAggregate(NewFile): subelement_obj = bpy.data.objects.new("Object", None) assert subject.can_aggregate(element_obj, subelement_obj) is False + def test_element_cannot_aggregate_to_itself(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + element = ifc.createIfcElementAssembly() + element_obj = bpy.data.objects.new("Object", None) + tool.Ifc.link(element, element_obj) + assert subject.can_aggregate(element_obj, element_obj) is False + + def test_cyclic_aggregation_is_prevented(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assembly_a = ifc.createIfcElementAssembly() + assembly_a_obj = bpy.data.objects.new("AssemblyA", None) + tool.Ifc.link(assembly_a, assembly_a_obj) + beam = ifc.createIfcBeam() + beam_obj = bpy.data.objects.new("Beam", None) + tool.Ifc.link(beam, beam_obj) + ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly_a) + assert subject.can_aggregate(beam_obj, assembly_a_obj) is False + + def test_deep_cyclic_aggregation_is_prevented(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assembly_a = ifc.createIfcElementAssembly() + assembly_a_obj = bpy.data.objects.new("AssemblyA", None) + tool.Ifc.link(assembly_a, assembly_a_obj) + assembly_b = ifc.createIfcElementAssembly() + assembly_b_obj = bpy.data.objects.new("AssemblyB", None) + tool.Ifc.link(assembly_b, assembly_b_obj) + beam = ifc.createIfcBeam() + beam_obj = bpy.data.objects.new("Beam", None) + tool.Ifc.link(beam, beam_obj) + ifcopenshell.api.aggregate.assign_object(ifc, products=[assembly_b], relating_object=assembly_a) + ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly_b) + assert subject.can_aggregate(beam_obj, assembly_a_obj) is False + class TestHasPhysicalBodyRepresentation(NewFile): def test_run(self): diff --git a/src/bonsai/test/tool/test_nest.py b/src/bonsai/test/tool/test_nest.py index 14acfa18bc..368ea9a923 100644 --- a/src/bonsai/test/tool/test_nest.py +++ b/src/bonsai/test/tool/test_nest.py @@ -19,6 +19,7 @@ import bpy import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.nest import ifcopenshell.api.spatial import bonsai.core.tool @@ -51,6 +52,42 @@ class TestCanNest(NewFile): subelement_obj = bpy.data.objects.new("Object", None) assert subject.can_nest(element_obj, subelement_obj) is False + def test_element_cannot_nest_to_itself(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + element = ifc.createIfcWall() + element_obj = bpy.data.objects.new("Object", None) + tool.Ifc.link(element, element_obj) + assert subject.can_nest(element_obj, element_obj) is False + + def test_cyclic_nesting_is_prevented(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_a = ifc.createIfcWall() + wall_a_obj = bpy.data.objects.new("WallA", None) + tool.Ifc.link(wall_a, wall_a_obj) + wall_b = ifc.createIfcWall() + wall_b_obj = bpy.data.objects.new("WallB", None) + tool.Ifc.link(wall_b, wall_b_obj) + ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_b], relating_object=wall_a) + assert subject.can_nest(wall_b_obj, wall_a_obj) is False + + def test_deep_cyclic_nesting_is_prevented(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_a = ifc.createIfcWall() + wall_a_obj = bpy.data.objects.new("WallA", None) + tool.Ifc.link(wall_a, wall_a_obj) + wall_b = ifc.createIfcWall() + wall_b_obj = bpy.data.objects.new("WallB", None) + tool.Ifc.link(wall_b, wall_b_obj) + wall_c = ifc.createIfcWall() + wall_c_obj = bpy.data.objects.new("WallC", None) + tool.Ifc.link(wall_c, wall_c_obj) + ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_b], relating_object=wall_a) + ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_c], relating_object=wall_b) + assert subject.can_nest(wall_c_obj, wall_a_obj) is False + class TestDisableEditing(NewFile): def test_run(self): From 75b8d4f218be186c77501a2a63cb0df843ca0692 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 22 Mar 2026 15:26:58 +1100 Subject: [PATCH 76/76] Remove spatial containment and aggregation when nesting The nest assign_object API now removes existing spatial containment and aggregate relationships before creating the nest, matching the behavior documented in its docstring and consistent with aggregate.assign_object. Fix #7248 Co-Authored-By: Claude Opus 4.6 --- .../ifcopenshell/api/nest/assign_object.py | 7 ++++++- .../test/api/nest/test_assign_object.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/nest/assign_object.py b/src/ifcopenshell-python/ifcopenshell/api/nest/assign_object.py index d3432b617f..579d0e7a11 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/nest/assign_object.py +++ b/src/ifcopenshell-python/ifcopenshell/api/nest/assign_object.py @@ -19,7 +19,9 @@ from typing import Union import ifcopenshell +import ifcopenshell.api.aggregate import ifcopenshell.api.owner +import ifcopenshell.api.spatial import ifcopenshell.guid import ifcopenshell.util.element @@ -137,7 +139,10 @@ def assign_object( if not objects_to_change: return is_nested_by - # NOTE: An object can both be nested and assigned to a container or an aggregate. + # Can be either only nested, aggregated, or contained at the same time. + possibly_contained = [o for o in objects_without_nests if hasattr(o, "ContainedInStructure")] + ifcopenshell.api.spatial.unassign_container(file, products=possibly_contained) + ifcopenshell.api.aggregate.unassign_object(file, products=objects_without_nests) # unassign elements from previous nests for nests in previous_nests_rels: diff --git a/src/ifcopenshell-python/test/api/nest/test_assign_object.py b/src/ifcopenshell-python/test/api/nest/test_assign_object.py index 88f8d0bb90..c83f796ac0 100644 --- a/src/ifcopenshell-python/test/api/nest/test_assign_object.py +++ b/src/ifcopenshell-python/test/api/nest/test_assign_object.py @@ -18,8 +18,10 @@ import pytest +import ifcopenshell.api.aggregate import ifcopenshell.api.nest import ifcopenshell.api.root +import ifcopenshell.api.spatial import ifcopenshell.util.element import test.bootstrap @@ -82,6 +84,24 @@ class TestAssignObject(test.bootstrap.IFC4): ifcopenshell.api.nest.assign_object(self.file, related_objects=subelements[2:3], relating_object=element2) assert rel.RelatedObjects == tuple(subelements[:2] + subelements[3:]) + def test_nesting_removes_spatial_containment(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + subelement = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + storey = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuildingStorey") + ifcopenshell.api.spatial.assign_container(self.file, products=[subelement], relating_structure=storey) + assert ifcopenshell.util.element.get_container(subelement) == storey + ifcopenshell.api.nest.assign_object(self.file, related_objects=[subelement], relating_object=element) + assert ifcopenshell.util.element.get_container(subelement) is None + + def test_nesting_removes_aggregate(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + subelement = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + assembly = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcElementAssembly") + ifcopenshell.api.aggregate.assign_object(self.file, products=[subelement], relating_object=assembly) + assert ifcopenshell.util.element.get_aggregate(subelement) == assembly + ifcopenshell.api.nest.assign_object(self.file, related_objects=[subelement], relating_object=element) + assert ifcopenshell.util.element.get_aggregate(subelement) is None + class TestAssignObjectIFC2X3(test.bootstrap.IFC2X3, TestAssignObject): pass