diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 7669edbd14..58f6a67065 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -1295,6 +1295,7 @@ class IfcImporter: if element.is_a("IfcSpace"): obj.hide_set(True) + class IfcImportSettings: """ Initialize only using `IfcImportSettings.factory()`. diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 1578f4eeed..93316a0d01 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -158,6 +158,7 @@ def menu_func(self, context): if element and element.is_a("IfcAnnotation") and element.ObjectType in ["SECTION", "ELEVATION"]: self.layout.operator("bim.activate_drawing_by_annotation", text="Go to Drawing") + def register(): if not bpy.app.background: bpy.utils.register_tool(workspace.AnnotationTool, after={"bim.bim_tool"}, separator=True, group=False) @@ -170,7 +171,7 @@ def register(): bpy.app.handlers.load_post.append(handler.load_post) bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler) bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button) - bpy.types.VIEW3D_MT_object_context_menu.append(menu_func) + bpy.types.VIEW3D_MT_object_context_menu.append(menu_func) def unregister(): diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 66c417f3ec..936225099b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -353,20 +353,20 @@ class CreateDrawing(bpy.types.Operator): # Clear any local camera setup and force viewport to use scene camera for area in context.screen.areas: - if area.type == 'VIEW_3D': + if area.type == "VIEW_3D": for space in area.spaces: - if space.type == 'VIEW_3D': + if space.type == "VIEW_3D": # Clear local camera to ensure we use scene.camera space.use_local_camera = False space.camera = context.scene.camera - space.region_3d.view_perspective = 'CAMERA' + space.region_3d.view_perspective = "CAMERA" print(f"Set viewport camera to: {context.scene.camera.name}") break - + # Force complete scene update context.view_layer.update() context.evaluated_depsgraph_get() - + underlay_svg = self.generate_underlay(context) with profile("Generate linework"): @@ -3078,9 +3078,9 @@ class AddReference(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): filter_glob: bpy.props.StringProperty(default="*.svg", options={"HIDDEN"}) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True) filename_ext = ".svg" - + files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement) - directory: bpy.props.StringProperty(subtype='DIR_PATH') + directory: bpy.props.StringProperty(subtype="DIR_PATH") def _execute(self, context): # Handle both single and multiple file selection @@ -3355,14 +3355,14 @@ class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator): for i, literal_backup in enumerate(literals_backup): if i < len(props.literals): literal_props = props.literals[i] - + if assigned_product_obj: literal_props.product_used = assigned_product_obj elif "product_used" in literal_backup and literal_backup["product_used"]: product_name = literal_backup["product_used"] if product_name in bpy.data.objects: literal_props.product_used = bpy.data.objects[product_name] - + literal_props.element_value_rows.clear() if "element_value_rows" in literal_backup: for row_data in literal_backup["element_value_rows"]: @@ -4251,63 +4251,62 @@ class ActivateDrawingByAnnotation(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Activate Drawing" bl_description = "Activate the drawing corresponding to the selected annotation" bl_options = {"REGISTER", "UNDO"} - + @classmethod def poll(cls, context): # Check if an annotation object is selected if not context.selected_objects: cls.poll_message_set("No object selected") return False - + active_obj = context.active_object if not active_obj: cls.poll_message_set("No active object") return False - + element = tool.Ifc.get_entity(active_obj) if not element: cls.poll_message_set("Selected object is not an IFC element") return False - + # Check if it's an IfcAnnotation with ObjectType = "SECTION" or "ELEVATION" if not element.is_a("IfcAnnotation") or element.ObjectType not in ["SECTION", "ELEVATION"]: cls.poll_message_set("Selected object is not a drawing annotation") return False - + return True def _execute(self, context): active_obj = context.active_object element = tool.Ifc.get_entity(active_obj) - + if not element or not element.is_a("IfcAnnotation") or element.ObjectType not in ["SECTION", "ELEVATION"]: self.report({"ERROR"}, "Selected object is not a drawing annotation") return {"CANCELLED"} - + # Find the drawing/camera element that this annotation references drawing_element = self.find_drawing_from_annotation(element) - + if not drawing_element: self.report({"ERROR"}, "Could not find drawing element for this annotation") return {"CANCELLED"} - + # Use the existing ActivateDrawing operator with the drawing element's ID bpy.ops.bim.activate_drawing(drawing=drawing_element.id()) - + return {"FINISHED"} - + def find_drawing_from_annotation(self, annotation_element): """Find the drawing/camera element that this annotation references.""" ifc = tool.Ifc.get() - + # Check IfcRelAssignsToProduct relationships for rel in ifc.get_inverse(annotation_element): if rel.is_a("IfcRelAssignsToProduct") and rel.RelatingProduct: if rel.RelatingProduct.is_a("IfcAnnotation"): # Found the drawing element! return rel.RelatingProduct - - + return None @@ -5062,7 +5061,7 @@ class AddElementValueRow(bpy.types.Operator): new_row.category = literal_props.category_for_adding new_row.element_key = "" new_row.formatted_value = "" - + if len(literal_props.element_value_rows) == 1: new_row.separator = "" else: @@ -5106,10 +5105,10 @@ class ElementValueSuggestionsPopup(bpy.types.Operator): row_index: bpy.props.IntProperty() category: bpy.props.StringProperty() search_query: bpy.props.StringProperty(name="Search", description="Search for element values") - + collection_keys: bpy.props.CollectionProperty(type=StrProperty) collection_descriptions: bpy.props.CollectionProperty(type=StrProperty) - + selected_key: bpy.props.StringProperty() def invoke(self, context, event): @@ -5157,13 +5156,13 @@ class ElementValueSuggestionsPopup(bpy.types.Operator): def draw(self, context): layout = self.layout - + layout.prop_search(self, "selected_key", self, "collection_descriptions", text="Value") def execute(self, context): if not self.selected_key: return {"CANCELLED"} - + obj = context.active_object if not obj: return {"CANCELLED"} @@ -5177,7 +5176,7 @@ class ElementValueSuggestionsPopup(bpy.types.Operator): return {"CANCELLED"} value_row = literal_props.element_value_rows[self.row_index] - + for idx, desc_item in enumerate(self.collection_descriptions): if desc_item.name == self.selected_key: actual_key = self.collection_keys[idx].name @@ -5260,10 +5259,7 @@ class FormatElementValueRow(bpy.types.Operator): custom_expression: bpy.props.StringProperty( name="Custom Expression", - description=( - "Custom expression using functions\n" - "Use {{value}} as placeholder for the current row's value." - ), + description=("Custom expression using functions\n" "Use {{value}} as placeholder for the current row's value."), default='concat({{value}}, " - additional text")', ) @@ -5288,51 +5284,51 @@ class FormatElementValueRow(bpy.types.Operator): def _load_formatting_from_row(self, row): """Parse the formatted_value to load existing formatting settings""" import re - + formatted_value = row.formatted_value - + if not formatted_value or formatted_value == f"{{{{{row.element_key}}}}}": self.formatting_type = "NONE" return - + if formatted_value.startswith("``") and formatted_value.endswith("``"): expression = formatted_value[2:-2].strip() else: self.formatting_type = "NONE" return - + if match := re.match(r"upper\(\{\{[^}]+\}\}\)", expression): self.formatting_type = "UPPER" - + elif match := re.match(r"lower\(\{\{[^}]+\}\}\)", expression): self.formatting_type = "LOWER" - + elif match := re.match(r"title\(\{\{[^}]+\}\}\)", expression): self.formatting_type = "TITLE" - + elif match := re.match(r"int\(\{\{[^}]+\}\}\)", expression): self.formatting_type = "INT" - + elif match := re.match(r"round\(\{\{[^}]+\}\},\s*([^)]+)\)", expression): self.formatting_type = "ROUND" self.round_precision = match.group(1).strip() - + elif match := re.match(r"number\(\{\{[^}]+\}\},\s*([^,]+),\s*([^)]+)\)", expression): self.formatting_type = "NUMBER" self.decimal_separator = match.group(1).strip() self.thousands_separator = match.group(2).strip() - + elif match := re.match(r"metric_length\(\{\{[^}]+\}\},\s*([^,]+),\s*([^)]+)\)", expression): self.formatting_type = "METRIC_LENGTH" self.metric_precision = match.group(1).strip() self.metric_decimals = int(match.group(2).strip()) - + elif match := re.match(r'imperial_length\(\{\{[^}]+\}\},\s*(\d+),\s*"([^"]+)",\s*"([^"]+)"\)', expression): self.formatting_type = "IMPERIAL_LENGTH" self.imperial_precision = int(match.group(1).strip()) self.imperial_input_unit = match.group(2).strip() self.imperial_output_unit = match.group(3).strip() - + else: self.formatting_type = "CUSTOM" self.custom_expression = expression @@ -5450,9 +5446,9 @@ class ApplyElementValueRowsToLiteral(bpy.types.Operator): default_format = f"{{{{{row.element_key}}}}}" row.formatted_value = default_format value_part = default_format - + parts.append(row.separator + value_part) - + concatenated_value = "".join(parts) for attr in literal_props.attributes: @@ -5469,12 +5465,12 @@ class ApplyElementValueRowsToLiteral(bpy.types.Operator): This preserves formatting functions like upper(), round(), etc. """ import re - - pattern = r'\{\{[^}]+\}\}' - + + pattern = r"\{\{[^}]+\}\}" + new_base_value = f"{{{{{new_element_key}}}}}" updated_value = re.sub(pattern, new_base_value, old_formatted_value) - + return updated_value diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index da04caf7ff..018c0a634a 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -714,7 +714,9 @@ class ElementValueRow(PropertyGroup): ) element_key: StringProperty( - name="Element Key", description="The element value key (e.g., 'id', 'Name', 'Pset_WallCommon.Reference')", default="" + name="Element Key", + description="The element value key (e.g., 'id', 'Name', 'Pset_WallCommon.Reference')", + default="", ) formatted_value: StringProperty( @@ -756,33 +758,33 @@ def get_category_items_with_counts(self, context): ("Coordinates", "Coordinates", "Coordinate information", "EMPTY_ARROWS"), ("Custom String", "Custom String", "Add custom text (no element key)", "SMALL_CAPS"), ] - + obj = context.active_object - + if obj and tool.Ifc.get_entity(obj): try: element = tool.Ifc.get_entity(obj) text_element = element - - if hasattr(self, 'product_used'): + + if hasattr(self, "product_used"): if self.product_used: element = tool.Ifc.get_entity(self.product_used) else: assigned = tool.Drawing.get_assigned_product(text_element) if assigned: element = assigned - + available_keys = ElementValuesData.get_available_element_value_keys(element) items = [] for i, (identifier, base_name, description, icon) in enumerate(category_metadata): count = len(available_keys.get(identifier, [])) display_name = f"{base_name} ({count})" if count > 0 else base_name items.append((identifier, display_name, description, icon, i)) - + return items except Exception as e: pass - + return [(id, name, desc, icon, i) for i, (id, name, desc, icon) in enumerate(category_metadata)] @@ -843,16 +845,16 @@ class LiteralProps(PropertyGroup): ) element_value_rows: CollectionProperty( - name="Element Value Rows", + name="Element Value Rows", type=ElementValueRow, - description="Collection of element value rows for building the literal value" + description="Collection of element value rows for building the literal value", ) category_for_adding: EnumProperty( name="Category for Adding", items=get_category_items_with_counts, default=0, - description="Category to use when adding a new element value row" + description="Category to use when adding a new element value row", ) if TYPE_CHECKING: diff --git a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py index d7ddd0b9f7..d1a3897a20 100644 --- a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py +++ b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py @@ -115,9 +115,13 @@ def parse_markdown_it(text: str) -> list[dict[str, Union[str, None]]]: if tokens[j].type == "inline": for child in tokens[j].children or []: if child.type == "softbreak": - segments.append({"text": None, "url": None, "break": True, "bold": False, "italic": False}) + segments.append( + {"text": None, "url": None, "break": True, "bold": False, "italic": False} + ) elif child.type == "html_inline" and child.content.strip().lower() == "
": - segments.append({"text": None, "url": None, "break": True, "bold": False, "italic": False}) + segments.append( + {"text": None, "url": None, "break": True, "bold": False, "italic": False} + ) elif child.type == "strong_open": bold = True elif child.type == "strong_close": @@ -133,11 +137,27 @@ def parse_markdown_it(text: str) -> list[dict[str, Union[str, None]]]: elif child.type == "link_close" and link_opening: url = link_opening.attrGet("href") if url and link_text: - segments.append({"text": link_text, "url": url, "break": False, "bold": bold, "italic": italic}) + segments.append( + { + "text": link_text, + "url": url, + "break": False, + "bold": bold, + "italic": italic, + } + ) link_opening = None link_text = None elif child.type == "text" and not link_opening: - segments.append({"text": child.content, "url": None, "break": False, "bold": bold, "italic": italic}) + segments.append( + { + "text": child.content, + "url": None, + "break": False, + "bold": bold, + "italic": italic, + } + ) j += 1 i = j else: @@ -168,7 +188,9 @@ def parse_markdown_it(text: str) -> list[dict[str, Union[str, None]]]: link_opening = None link_text = None elif child.type == "text" and not link_opening: - segments.append({"text": child.content, "url": None, "break": False, "bold": bold, "italic": italic}) + segments.append( + {"text": child.content, "url": None, "break": False, "bold": bold, "italic": italic} + ) i += 1 segments = [seg for seg in segments if seg.get("text") is not None or seg.get("break", False)] if not segments: @@ -260,7 +282,7 @@ class SvgWriter: paths = self.resource_paths["Stylesheet"] if not paths: return - path_list = [p.strip() for p in paths.split(',')] + path_list = [p.strip() for p in paths.split(",")] for path in path_list: if not os.path.exists(path): print(f"WARNING. Couldn't find stylesheet for the drawing by the path: {path}") diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index c781d6ea23..d230add8f5 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -476,7 +476,6 @@ class BIM_PT_sheets(Panel): op = row3.operator("bim.activate_drawing_from_sheet", icon="OUTLINER_OB_CAMERA", text="") - if active_sheet.reference_type == "DRAWING": drawingnamesvg = active_sheet.name drawingname = drawingnamesvg.split(".svg")[0] @@ -680,13 +679,13 @@ class BIM_PT_text(Panel): if len(literal_props.attributes) > 0 and i < len(props.literal_apply_settings): row = box.row(align=True) bonsai.bim.helper.draw_attribute(literal_props.attributes[0], row, enable_search=True) - + expand_icon = "DOWNARROW_HLT" if getattr(literal_props, "show_element_values", False) else "RIGHTARROW" op = row.operator("bim.toggle_element_values_panel", icon=expand_icon, text="") op.literal_prop_id = i - + row.prop(props.literal_apply_settings[i], "apply_text_to_all", text="", icon="COPYDOWN") - + element = tool.Ifc.get_entity(obj) assigned_element = tool.Drawing.get_assigned_product(element) or element resolved_value = tool.Drawing.replace_text_literal_variables( @@ -711,8 +710,10 @@ class BIM_PT_text(Panel): element_values_row.prop(literal_props, "product_used", text="", icon="EYEDROPPER") current_product = get_current_product_for_element_values(obj, literal_props) - - product_name = current_product.name if (current_product and hasattr(current_product, "name")) else "Unknown" + + product_name = ( + current_product.name if (current_product and hasattr(current_product, "name")) else "Unknown" + ) source_row = values_box.row() source_row.label(text=f"Source: {product_name}", icon="OBJECT_DATA") @@ -720,29 +721,29 @@ class BIM_PT_text(Panel): if element: add_row = values_box.row(align=True) add_row.prop(literal_props, "category_for_adding", text="") - + op = add_row.operator("bim.add_element_value_row", text="Add Element", icon="ADD") op.literal_prop_id = i if len(literal_props.element_value_rows) > 0: for row_idx, value_row in enumerate(literal_props.element_value_rows): row = values_box.row(align=True) - + is_custom_string = value_row.category == "Custom String" - + if is_custom_string: category_icon = get_category_icon(value_row.category) row.prop(value_row, "element_key", text="", icon=category_icon) else: split = row.split(factor=0.25, align=True) - + sep_col = split.row(align=True) sep_col.prop(value_row, "separator", text="") - + key_col = split.row(align=True) category_icon = get_category_icon(value_row.category) key_col.prop(value_row, "element_key", text="", icon=category_icon) - + op = row.operator("bim.element_value_suggestions_popup", text="", icon="VIEWZOOM") op.literal_prop_id = i op.row_index = row_idx @@ -758,7 +759,9 @@ class BIM_PT_text(Panel): apply_row = values_box.row() apply_row.scale_y = 1.2 - op = apply_row.operator("bim.apply_element_value_rows_to_literal", text="Apply to Literal", icon="CHECKMARK") + op = apply_row.operator( + "bim.apply_element_value_rows_to_literal", text="Apply to Literal", icon="CHECKMARK" + ) op.literal_prop_id = i else: error_row = values_box.row() diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 24ad1cb013..85e76dc590 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -1208,7 +1208,7 @@ class OverrideDuplicateMove(bpy.types.Operator): # Expand selection to include all parts of selected aggregates objects_to_duplicate = set(context.selected_objects) - objects_to_remove expanded_objects = set(objects_to_duplicate) - + for obj in objects_to_duplicate: element = tool.Ifc.get_entity(obj) if element and element.is_a("IfcElementAssembly"): @@ -1217,33 +1217,33 @@ class OverrideDuplicateMove(bpy.types.Operator): part_obj = tool.Ifc.get_object(part) if part_obj: expanded_objects.add(part_obj) - + # Store parent aggregate relationships parent_aggregates = {} - + for obj in expanded_objects: element = tool.Ifc.get_entity(obj) if element and element.is_a("IfcElementAssembly"): parent_aggregate = ifcopenshell.util.element.get_aggregate(element) if parent_aggregate: parent_aggregates[element] = parent_aggregate - + old_to_new, new_active_obj = tool.Geometry.duplicate_ifc_objects( expanded_objects, linked=linked, active_object=context.active_object, ) - + # Restore parent aggregate relationships, but only for parents that were NOT duplicated for old_elem, new_elems in old_to_new.items(): if old_elem in parent_aggregates: old_parent = parent_aggregates[old_elem] - + # Check if the parent was also duplicated if old_parent in old_to_new: # The duplication already created the correct nested relationship continue - + # Parent was NOT duplicated, so we need to assign to the original parent for new_elem in new_elems: new_obj = tool.Ifc.get_object(new_elem) @@ -1256,7 +1256,7 @@ class OverrideDuplicateMove(bpy.types.Operator): relating_obj=parent_obj, related_obj=new_obj, ) - + # Select all duplicated objects and their parts all_objects_to_select = set() for old_elem, new_elems in old_to_new.items(): @@ -1264,7 +1264,7 @@ class OverrideDuplicateMove(bpy.types.Operator): new_obj = tool.Ifc.get_object(new_elem) if new_obj: all_objects_to_select.add(new_obj) - + # If it's an aggregate, also select all its parts if new_elem.is_a("IfcElementAssembly"): parts = tool.Aggregate.get_parts_recursively(new_elem) @@ -1272,17 +1272,17 @@ class OverrideDuplicateMove(bpy.types.Operator): part_obj = tool.Ifc.get_object(part) if part_obj: all_objects_to_select.add(part_obj) - + # Deselect everything first - bpy.ops.object.select_all(action='DESELECT') - + bpy.ops.object.select_all(action="DESELECT") + # Select all the duplicated objects for obj in all_objects_to_select: obj.select_set(True) - + if new_active_obj: context.view_layer.objects.active = new_active_obj - + return old_to_new @@ -1614,7 +1614,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): if r.is_a("IfcRelAssignsToGroup") if self.group_name in r.RelatingGroup.Name ).id() - + # Initialize if not exists if group not in original_data: original_data[group] = {} @@ -1666,20 +1666,22 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): # Get the new group new_group_entity = next( - (r.RelatingGroup - for r in getattr(aggregate, "HasAssignments", []) or [] - if r.is_a("IfcRelAssignsToGroup") - if self.group_name in r.RelatingGroup.Name), - None + ( + r.RelatingGroup + for r in getattr(aggregate, "HasAssignments", []) or [] + if r.is_a("IfcRelAssignsToGroup") + if self.group_name in r.RelatingGroup.Name + ), + None, ) - + if not new_group_entity: return pset = ifcopenshell.util.element.get_pset(element, self.pset_name) if not pset: return - + index = pset["Index"] # Find the matching old group by looking for the same aggregate name @@ -1697,7 +1699,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): if index in group_data: matching_group_id = group_id break - + if matching_group_id is None: return @@ -1709,7 +1711,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): ifc_file.by_id(pset["id"]), properties={"Aggregate_Index": int(original_data[matching_group_id][index]["Aggregate_Index"])}, ) - + # Only assign container if element is not already aggregated under another element # Aggregated elements should not be in the spatial structure if not ifcopenshell.util.element.get_aggregate(element): @@ -1722,7 +1724,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): ) for part in ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(obj)): tool.Collector.assign(tool.Ifc.get_object(part)) - + assignments = original_data[matching_group_id][index]["Assignment"] if assignments: assign_to_annotations(obj, assignments) @@ -1837,7 +1839,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): base_pset = ifcopenshell.util.element.get_pset(base_instance, self.pset_name) base_obj = tool.Ifc.get_object(base_instance) base_obj.name = base_pset["Name"] + "_" + str(base_pset["Aggregate_Index"]) - + for element in instances_to_refresh: if element.GlobalId == base_instance.GlobalId: continue diff --git a/src/bonsai/bonsai/bim/module/material/data.py b/src/bonsai/bonsai/bim/module/material/data.py index 3df1506fdc..b56ff93e33 100644 --- a/src/bonsai/bonsai/bim/module/material/data.py +++ b/src/bonsai/bonsai/bim/module/material/data.py @@ -433,29 +433,29 @@ class ObjectMaterialData: """Load BBIM_MaterialLayer pset data for display in UI.""" if not cls.element: return None - + pset_data = ifcopenshell.util.element.get_pset(cls.element, "BBIM_MaterialLayer") if not pset_data or not pset_data.get("UseCustomOffset", False): return None - + # Keep offset in SI units - format_distance will handle conversion custom_offset_si = pset_data.get("CustomOffset", 0.0) - + # Get the appropriate reference based on usage type usage_type = tool.Model.get_usage_type(cls.element) custom_reference = None reference_label = None - + if usage_type == "LAYER2": custom_reference = pset_data.get("CustomWallReference", "") reference_label = "Wall Reference" elif usage_type == "LAYER3": custom_reference = pset_data.get("CustomSlabReference", "") reference_label = "Slab Reference" - + return { "use_custom_offset": pset_data.get("UseCustomOffset", False), "custom_offset": custom_offset_si, # Store in SI units "custom_reference": custom_reference, "reference_label": reference_label, - } \ No newline at end of file + } diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index e8243ccf1e..f46f2c7b6a 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -509,10 +509,10 @@ class EnableEditingAssignedMaterial(bpy.types.Operator): bonsai.bim.helper.import_attributes(material[0], props.material_set_attributes) else: bonsai.bim.helper.import_attributes(material, props.material_set_attributes) - + # Load custom offset from BBIM_MaterialLayer pset tool.Model.load_custom_offset_from_pset(element, obj) - + return {"FINISHED"} def import_attributes_callback( @@ -625,7 +625,7 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator): obj_material_usage.ReferenceExtent = material.ReferenceExtent layer_sets_to_regenerate.add(obj_material_usage.ForLayerSet) - + # Save custom offset to BBIM_MaterialLayer pset tool.Model.save_custom_offset_to_pset(obj_element, obj) diff --git a/src/bonsai/bonsai/bim/module/material/ui.py b/src/bonsai/bonsai/bim/module/material/ui.py index 969661885a..c1b9ac2ae4 100644 --- a/src/bonsai/bonsai/bim/module/material/ui.py +++ b/src/bonsai/bonsai/bim/module/material/ui.py @@ -233,7 +233,7 @@ class BIM_PT_object_material(Panel): # Material Set Attributes Section row = self.layout.row(align=True) box = row.box() - + bonsai.bim.helper.draw_attributes(self.props.material_set_attributes, box) bonsai.bim.helper.draw_attributes(self.props.material_set_usage_attributes, box) @@ -246,7 +246,7 @@ class BIM_PT_object_material(Panel): "layer": "Material Layers", "profile": "Material Profiles", "constituent": "Material Constituents", - "list_item": "Material List Items" + "list_item": "Material List Items", } header_text = header_map.get(set_item_name, "Material Items") self.layout.label(text=header_text) @@ -255,7 +255,7 @@ class BIM_PT_object_material(Panel): row = self.layout.row(align=True) box = row.box() - + # Add Material Section (at the top of this box) if ObjectMaterialData.data["set_item_name"] == "profile" and not self.mprops.profiles: box_row = box.row(align=True) @@ -268,7 +268,7 @@ class BIM_PT_object_material(Panel): prop_with_search(box_row, self.props, "material", icon="MATERIAL", text="") op = box_row.operator(f"bim.add_{ObjectMaterialData.data['set_item_name']}", icon="ADD", text="") setattr(op, f"{ObjectMaterialData.data['set_item_name']}_set", ObjectMaterialData.data["set"]["id"]) - + active_object = bpy.context.active_object self.layerset_bounds(box, active_object, location="Top_Interior") @@ -356,7 +356,7 @@ class BIM_PT_object_material(Panel): # Material Set Information Section row = self.layout.row(align=True) box = row.box() - + if ObjectMaterialData.data["material_class"] != "IfcMaterialList": box_row = box.row(align=True) set_name = ObjectMaterialData.data["set"]["name"] @@ -395,6 +395,7 @@ class BIM_PT_object_material(Panel): if unit_system == "IMPERIAL": precision = prefs.doc.imperial_precision from bonsai.bim.module.drawing.helper import format_distance + formatted_offset = format_distance( offset_value, precision=precision, suppress_zero_inches=True, in_unit_length=True ) @@ -405,10 +406,10 @@ class BIM_PT_object_material(Panel): # BBIM_MaterialLayer Pset Section if pset_data := ObjectMaterialData.data.get("bbim_material_layer_pset"): self.layout.label(text="BBIM_MaterialLayer Pset") - + row = self.layout.row(align=True) box = row.box() - + # Custom Offset value - format using format_distance unit_system = bpy.context.scene.unit_settings.system prefs = tool.Blender.get_addon_preferences() @@ -416,13 +417,14 @@ class BIM_PT_object_material(Panel): if unit_system == "IMPERIAL": precision = prefs.doc.imperial_precision from bonsai.bim.module.drawing.helper import format_distance + formatted_custom_offset = format_distance( - pset_data['custom_offset'], precision=precision, suppress_zero_inches=True, in_unit_length=True + pset_data["custom_offset"], precision=precision, suppress_zero_inches=True, in_unit_length=True ) box_row = box.row(align=True) box_row.label(text="Custom Offset") box_row.label(text=formatted_custom_offset) - + # Reference (if exists) if pset_data["custom_reference"]: box_row = box.row(align=True) @@ -436,12 +438,12 @@ class BIM_PT_object_material(Panel): "layer": "Material Layers", "profile": "Material Profiles", "constituent": "Material Constituents", - "list_item": "Material List Items" + "list_item": "Material List Items", } header_text = header_map.get(set_item_name, "Material Items") else: header_text = "Materials" - + self.layout.label(text=header_text) row = self.layout.row(align=True) box = row.box() @@ -492,11 +494,11 @@ class BIM_PT_object_material(Panel): if layer_set_direction: row = self.layout.row(align=True) row.label(text="BBIM_MaterialLayer Pset") - + # Add indentation with a row that has a separator row = self.layout.row(align=True) # row.separator(factor=2.0) # Adjust factor for more/less indent - + box = row.box() box_row = box.row(align=True) box_row.prop(self.props, "use_custom_offset", text="Use Custom Offset") diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index b1f91a6398..256251b06c 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -170,7 +170,7 @@ class FilledOpeningGenerator: 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) @@ -191,25 +191,25 @@ class FilledOpeningGenerator: MappingSource=existing_mapping_source, MappingTarget=tool.Ifc.get().create_entity( "IfcCartesianTransformationOperator3D", - Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1., 0., 0.)), - Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0., 1., 0.)), - LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0., 0., 0.)), - Scale=1., - Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0., 0., 1.)) - ) + 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] + Items=[new_mapped_item], ) else: 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 ) @@ -333,25 +333,25 @@ class FilledOpeningGenerator: MappingSource=existing_mapping_source, MappingTarget=tool.Ifc.get().create_entity( "IfcCartesianTransformationOperator3D", - Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1., 0., 0.)), - Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0., 1., 0.)), - LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0., 0., 0.)), - Scale=1., - Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0., 0., 1.)) - ) + 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] + Items=[new_mapped_item], ) else: mapped_representation = ifcopenshell.api.geometry.map_representation( tool.Ifc.get(), representation=representation_to_use ) - + ifcopenshell.api.geometry.assign_representation( tool.Ifc.get(), product=opening, representation=mapped_representation ) diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index dd65f9c1c1..2b2c3a2984 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -297,13 +297,13 @@ class DumbSlabPlaner: extrusion = tool.Model.get_extrusion(representation) if extrusion: direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) - + # Calculate the actual extrusion angle from vertical extrusion_angle = 0 if direction_ratios.length > 0: cos_angle = direction_ratios.normalized().dot(Vector((0, 0, 1))) extrusion_angle = acos(min(max(cos_angle, -1), 1)) - + # FIX: Only apply 1/cos factor when there's actual extrusion slope if extrusion_angle > 1e-6: perpendicular_depth = thickness * abs(1 / cos(extrusion_angle)) @@ -311,13 +311,13 @@ class DumbSlabPlaner: else: perpendicular_depth = thickness perpendicular_offset = layer_offset / self.unit_scale - + # Check if direction sense needs to be applied # This should only happen if explicitly requested, not automatically if layer_params.get("apply_direction_sense", False): # Store current direction before potential change old_direction = direction_ratios.copy() - + # Apply direction sense logic existing_x_angle = extrusion_angle if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or ( @@ -331,26 +331,26 @@ class DumbSlabPlaner: offset_direction = direction_ratios.copy() * -1 if layer_params["direction_sense"] == "POSITIVE": direction_ratios *= -1 - + # If direction changed, update extrusion with rotation compensation if (direction_ratios.normalized() - old_direction.normalized()).length > 1e-6: update_extrusion_direction(element, tuple(direction_ratios), obj) # After updating direction, get the updated extrusion extrusion = tool.Model.get_extrusion(representation) - + # Update depth extrusion.Depth = perpendicular_depth - + # Update position ifc_position = extrusion.Position if direction_ratios.length > 0: offset_vector = direction_ratios.normalized() * perpendicular_offset position = offset_vector - + material = ifcopenshell.util.element.get_material(element) if material and material.is_a("IfcMaterialLayerSetUsage"): material.OffsetFromReferenceLine = position.z - + if ifc_position: ifc_position.Location.Coordinates = position else: @@ -397,13 +397,12 @@ class DumbSlabPlaner: representation=representation, ) - - def update_extrusion_direction(element: ifcopenshell.entity_instance, - new_direction_ratios: tuple, - obj: bpy.types.Object = None) -> None: + def update_extrusion_direction( + element: ifcopenshell.entity_instance, new_direction_ratios: tuple, obj: bpy.types.Object = None + ) -> None: """ Update extrusion direction while preserving overall object orientation. - + Args: element: The IFC element new_direction_ratios: New extrusion direction ratios (x,y,z) @@ -413,66 +412,66 @@ class DumbSlabPlaner: obj = tool.Ifc.get_object(element) if not obj: return - + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: return - + extrusion = tool.Model.get_extrusion(representation) if not extrusion: return - + # Get current extrusion direction old_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios) if old_direction.length == 0: old_direction = Vector((0, 0, 1)) # Default - + new_direction = Vector(new_direction_ratios) if new_direction.length == 0: new_direction = Vector((0, 0, 1)) # Default - + # Normalize both directions old_direction_normalized = old_direction.normalized() new_direction_normalized = new_direction.normalized() - + # Store current object matrix old_matrix = obj.matrix_world.copy() - + # Calculate the rotation needed to keep same orientation # When extrusion direction changes from A to B relative to local coordinates, # we need to rotate the object by the inverse of that change - + # Calculate rotation from old to new direction rotation_axis = old_direction_normalized.cross(new_direction_normalized) if rotation_axis.length > 1e-6: rotation_axis.normalized() dot_product = old_direction_normalized.dot(new_direction_normalized) angle = acos(min(max(dot_product, -1), 1)) - + # Apply INVERSE rotation to object to compensate rotation_matrix = Matrix.Rotation(-angle, 4, rotation_axis) - + # Update object rotation obj.matrix_world = old_matrix @ rotation_matrix bpy.context.view_layer.update() - + # Update extrusion direction (keeping magnitude) if old_direction.length > 0: # Preserve the magnitude of the original direction vector magnitude = old_direction.length new_direction = new_direction_normalized * magnitude - + extrusion.ExtrudedDirection.DirectionRatios = tuple(new_direction) - + # Update depth based on new extrusion angle extrusion_angle = 0 if new_direction.length > 0: cos_angle = new_direction_normalized.dot(Vector((0, 0, 1))) extrusion_angle = acos(min(max(cos_angle, -1), 1)) - + # Get current depth (perpendicular depth) current_perpendicular_depth = extrusion.Depth - + # If we have material layer info, calculate actual thickness material = ifcopenshell.util.element.get_material(element) actual_thickness = current_perpendicular_depth @@ -481,15 +480,15 @@ class DumbSlabPlaner: actual_thickness = sum([l.LayerThickness for l in layer_set.MaterialLayers]) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) actual_thickness *= unit_scale - + # Convert to perpendicular depth if needed if extrusion_angle > 1e-6: new_perpendicular_depth = actual_thickness * abs(1 / cos(extrusion_angle)) else: new_perpendicular_depth = actual_thickness - + extrusion.Depth = new_perpendicular_depth - + # Update position offset if needed if extrusion.Position: # Recalculate offset based on new direction @@ -500,7 +499,7 @@ class DumbSlabPlaner: perpendicular_offset = offset * abs(1 / cos(extrusion_angle)) else: perpendicular_offset = offset - + offset_vector = new_direction_normalized * perpendicular_offset extrusion.Position.Location.Coordinates = tuple(offset_vector) @@ -778,7 +777,7 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): extrusion = tool.Model.get_extrusion(body) existing_x_angle = tool.Model.get_existing_x_angle(extrusion) layer_params = tool.Model.get_material_layer_parameters(element) - + usage_type = tool.Model.get_usage_type(element) if extrusion.Position: @@ -798,7 +797,7 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): # Store original rotation for later restoration original_rotation_x = obj.rotation_euler.x obj["pre_edit_rotation_x"] = original_rotation_x - + # Reset rotation to zero - profile will be horizontal current_z_rot = obj.rotation_euler.z obj.rotation_euler.x = 0.0 @@ -819,12 +818,12 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): # For LAYER3: Use x_angle=0 and scale by cos(rotation) to get horizontal projection obj_x_rotation = original_rotation_x # Use stored original rotation scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0 - + # Import with x_angle=0 tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=0) - + # Scale the Y coordinates by cos(rotation) to get horizontal projection - bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.mode_set(mode="OBJECT") for vert in obj.data.vertices: vert.co.y *= scale_factor else: @@ -835,7 +834,7 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_extrusion_profile(context)) if not bpy.app.background: tool.Blender.set_viewport_tool("bim.cad_tool") - + return {"FINISHED"} @@ -858,7 +857,7 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): existing_x_angle = tool.Model.get_existing_x_angle(extrusion) layer_params = tool.Model.get_material_layer_parameters(element) usage_type = tool.Model.get_usage_type(element) - + if extrusion.Position: position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist()) position.translation *= self.unit_scale @@ -895,16 +894,17 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): # Scale Y coordinates back up before exporting obj_x_rotation = obj.rotation_euler.x scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0 - + # Un-scale the profile before exporting for vert in obj.data.vertices: vert.co.y /= scale_factor # Inverse of import scaling - + profile = tool.Model.export_profile(obj, position=position, x_angle=0) else: profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle) if not profile: + def msg(self, context): self.layout.label(text="INVALID PROFILE") @@ -953,7 +953,6 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): tool.Ifc.get(), product=element, representation=new_footprint ) - footprint_context = ifcopenshell.util.representation.get_context( tool.Ifc.get(), "Plan", "FootPrint", "SKETCH_VIEW" ) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 8557ae6ccb..d894aa71d1 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -397,28 +397,28 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): for obj in selected_objs: element = tool.Ifc.get_entity(obj) assert element - + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: continue - + extrusion = tool.Model.get_extrusion(representation) if not extrusion: continue - + # Get extrusion direction x, y, z = extrusion.ExtrudedDirection.DirectionRatios - + # Calculate angle from vertical x_angle = Vector((0, 1)).angle_signed(Vector((y, z))) - + # For sloped walls, compensate so VERTICAL height = target depth cos_angle = cos(x_angle) compensation_factor = abs(1 / cos_angle) if abs(cos_angle) > 1e-6 else 1.0 new_depth_ifc = (self.depth / si_conversion) * compensation_factor - + extrusion.Depth = new_depth_ifc - + # IMPORTANT: Refresh the geometry to reflect the IFC changes bonsai.core.geometry.switch_representation( tool.Ifc, @@ -426,7 +426,7 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): obj=obj, representation=representation, ) - + if tool.Model.get_usage_type(element) == "LAYER2": for rel in element.ConnectedFrom: if rel.is_a() == "IfcRelConnectsElements": @@ -436,7 +436,7 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): if layer2_objs: tool.Model.recalculate_walls(layer2_objs) - + return {"FINISHED"} @@ -471,51 +471,55 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): extrusion = tool.Model.get_extrusion(representation) if not extrusion: continue - + # Get current object rotation matrix obj_rotation = obj.matrix_world.to_3x3() - + # Get current extrusion direction in LOCAL coordinates current_local_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios) if current_local_direction.length == 0: current_local_direction = Vector((0, 0, 1)) current_local_direction_normalized = current_local_direction.normalized() - + # Calculate what the current extrusion direction is in WORLD coordinates current_world_direction = obj_rotation @ current_local_direction_normalized - + existing_x_angle = tool.Model.get_existing_x_angle(extrusion) existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle - + # Calculate the NEW local extrusion direction based on x_angle new_local_direction = Vector((0.0, sin(x_angle), cos(x_angle))) - + # Check if extrusion direction is actually changing current_local_norm = current_local_direction_normalized new_local_norm = new_local_direction.normalized() - + # Compare the LOCAL directions local_direction_changed = (new_local_norm - current_local_norm).length > 1e-6 - + if tool.Model.get_usage_type(element) == "LAYER2": depth = extrusion.Depth / abs(1 / cos(existing_x_angle)) perpendicular_depth = depth * abs(1 / cos(x_angle)) - + # Update extrusion direction if local_direction_changed: extrusion.ExtrudedDirection.DirectionRatios = tuple(new_local_direction) - + # Always update depth extrusion.Depth = perpendicular_depth layer2_objs.append(obj) - + else: if tool.Model.get_usage_type(element) == "LAYER3": # For slabs, handle polyline scaling existing_obj_x_angle = obj.rotation_euler.x - existing_obj_x_angle = 0 if tool.Cad.is_x(existing_obj_x_angle, 0, tolerance=0.001) else existing_obj_x_angle - existing_obj_x_angle = 0 if tool.Cad.is_x(existing_obj_x_angle, pi, tolerance=0.001) else existing_obj_x_angle + existing_obj_x_angle = ( + 0 if tool.Cad.is_x(existing_obj_x_angle, 0, tolerance=0.001) else existing_obj_x_angle + ) + existing_obj_x_angle = ( + 0 if tool.Cad.is_x(existing_obj_x_angle, pi, tolerance=0.001) else existing_obj_x_angle + ) # Scale the polyline coordinates coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve) @@ -547,11 +551,11 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): offset_direction *= -1 if layer_params["direction_sense"] == "POSITIVE": final_local_direction *= -1 - + # Check if extrusion direction actually changed final_local_norm = final_local_direction.normalized() local_direction_changed = (final_local_norm - current_local_norm).length > 1e-6 - + # Update extrusion properties extrusion.ExtrudedDirection.DirectionRatios = tuple(final_local_direction) extrusion.Depth = perpendicular_depth @@ -559,19 +563,19 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): if extrusion.Position or perpendicular_offset != 0: position = offset_direction * perpendicular_offset tool.Model.add_extrusion_position(extrusion, position) - + # Adjust object rotation if extrusion direction changed if local_direction_changed: # Calculate what the NEW world direction would be with current object rotation expected_new_world_direction = obj_rotation @ final_local_norm - + # The rotation needed is from expected_new_world_direction to current_world_direction rotation_axis = expected_new_world_direction.cross(current_world_direction) if rotation_axis.length > 1e-6: rotation_axis.normalize() dot_product = expected_new_world_direction.dot(current_world_direction) angle = acos(min(max(dot_product, -1), 1)) - + # Create and apply rotation matrix rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis) obj.matrix_world = rotation_matrix @ obj.matrix_world @@ -1081,7 +1085,7 @@ class DumbWallGenerator: obj=obj, representation=representation, ) - + pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="EPset_Parametric") ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Engine": "Bonsai.DumbLayer2"}) material = ifcopenshell.util.element.get_material(element) diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index be02f3f4c8..578fbefa2a 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -399,7 +399,7 @@ class EditItemUI: assert obj mesh_props = tool.Geometry.get_mesh_props(obj.data) - + # Get the parent element from representation_obj to check for layer set usage has_layer_set_usage = False props = tool.Geometry.get_geometry_props() @@ -408,7 +408,7 @@ class EditItemUI: if parent_element: material_usage = tool.Model.get_usage_type(parent_element) has_layer_set_usage = material_usage == "LAYER3" - + if AuthoringData.data["is_representation_item_swept_solid"]: # TODO: support EndSweptArea for IfcRevolvedAreaSolidTapered, # will need to add second attribute for this. @@ -427,7 +427,7 @@ class EditItemUI: continue row = cls.layout.row() draw_attribute(item_attribute, cls.layout) - + if len(mesh_props.item_attributes) or AuthoringData.data["is_representation_item_swept_solid"]: row = cls.layout.row() row.operator("bim.update_item_attributes", icon="FILE_REFRESH", text="") diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index a549f7d69e..b0f74cfb40 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1062,7 +1062,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): and not self.is_advanced ): filepath = self.get_filepath() - + # First, load the IFC file temporarily to check for metadata document temp_ifc = None has_metadata_doc = False @@ -1076,7 +1076,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): pass finally: temp_ifc = None - + if has_metadata_doc: suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix if str(filepath).lower().endswith(".ifc"): @@ -1137,10 +1137,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): props.is_loading = True props.total_elements = len(tool.Ifc.get().by_type("IfcElement")) props.use_relative_project_path = self.use_relative_path - + metadata_doc = tool.Project.get_metadata_document_information() props.should_save_metadata_for_this_file = metadata_doc is not None - + tool.Blender.register_toolbar() tool.Project.add_recent_ifc_project(self.get_filepath_abs()) @@ -1776,7 +1776,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper): metadata_filename = os.path.basename(output_file)[:-4] + suffix else: metadata_filename = os.path.basename(output_file) + suffix - + if not tool.Project.get_metadata_document_information(): tool.Project.create_metadata_document_information(metadata_filename) else: @@ -3165,6 +3165,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): return {"FINISHED"} + class LoadBlendMetadataAndIFC(bpy.types.Operator): bl_idname = "bim.load_blend_metadata_and_ifc" bl_label = "Load Blend Metadata and IFC" @@ -3202,4 +3203,4 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator): bpy.app.handlers.load_post.append(load_handler) bpy.ops.wm.open_mainfile(filepath=metadata_path) - return {"FINISHED"} \ No newline at end of file + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/operator.py b/src/bonsai/bonsai/bim/operator.py index d8c1cab7d1..8cfc3fb0d4 100644 --- a/src/bonsai/bonsai/bim/operator.py +++ b/src/bonsai/bonsai/bim/operator.py @@ -364,8 +364,6 @@ bpy.ops.wm.save_as_mainfile(filepath=r'{blendmetadata_path}') return {"FINISHED"} - - # TODO: Unused operator. # Is there a need for this or 'DIR_PATH' propety subtype does almost the same, # but also has alt+click? diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index f799076727..75f12e49d8 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -638,7 +638,7 @@ class BIMProperties(PropertyGroup): ], name="Time Unit", default="HOUR", - ) + ) tab_visibilities: CollectionProperty(type=BIMTabVisibility, name="Tab Visibilities") panel_properties: CollectionProperty(type=BIMPanelProperties, name="Panel Properties") diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 407bb19cb0..6ce74fe394 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -402,7 +402,7 @@ def update_drawing_name( camera = ifc.get_object(drawing) if camera and camera.name != name: camera.name = name - + group = drawing_tool.get_drawing_group(drawing) if drawing_tool.get_name(group) != name: ifc.run("attribute.edit_attributes", product=group, attributes={"Name": name}) diff --git a/src/bonsai/bonsai/core/root.py b/src/bonsai/bonsai/core/root.py index 957298c044..b8f960247c 100644 --- a/src/bonsai/bonsai/core/root.py +++ b/src/bonsai/bonsai/core/root.py @@ -63,20 +63,20 @@ def copy_class( def _has_material_styles(ifc: type[tool.Ifc], element: ifcopenshell.entity_instance) -> bool: """Check if element has styles defined through its material. - + Returns True if any constituent material has a style representation, which means styles should NOT be applied directly to the geometry. """ materials = ifcopenshell.util.element.get_materials(element) - + if not materials: return False - + # Check if any of the constituent materials have styles for material in materials: - if hasattr(material, 'HasRepresentation') and material.HasRepresentation: + if hasattr(material, "HasRepresentation") and material.HasRepresentation: return True - + return False diff --git a/src/bonsai/bonsai/core/type.py b/src/bonsai/bonsai/core/type.py index 7926c53ff1..2918e841f7 100644 --- a/src/bonsai/bonsai/core/type.py +++ b/src/bonsai/bonsai/core/type.py @@ -33,17 +33,16 @@ def assign_type( element: ifcopenshell.entity_instance, type: ifcopenshell.entity_instance, ) -> None: - - + # Get the instance's current CardinalPoint before type assignment instance_cardinal_point = None instance_material = ifcopenshell.util.element.get_material(element) if instance_material and instance_material.is_a("IfcMaterialProfileSetUsage"): instance_cardinal_point = instance_material.CardinalPoint - + ifc.run("type.assign_type", related_objects=[element], relating_type=type) obj = ifc.get_object(element) - + if type_tool.has_material_usage(element): # Restore the instance's CardinalPoint to the new material usage if instance_cardinal_point is not None: @@ -51,16 +50,17 @@ def assign_type( if new_instance_material and new_instance_material.is_a("IfcMaterialProfileSetUsage"): if new_instance_material.CardinalPoint != instance_cardinal_point: new_instance_material.CardinalPoint = instance_cardinal_point - + # Force representation regeneration from bonsai.bim.module.model.profile import DumbProfileRecalculator + DumbProfileRecalculator().recalculate([obj]) # for now, representation regeneration handled by API listeners else: type_data = type_tool.get_object_data(ifc.get_object(type)) if type_data: type_tool.change_object_data(obj, type_data, is_global=False) - + type_tool.disable_editing(obj) diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 09bfcf29cc..e45e2b1206 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -382,17 +382,14 @@ class Blender(bonsai.core.tool.Blender): assert isinstance(space, bpy.types.SpaceNodeEditor) if space.tree_type == "ShaderNodeTree": context_override = {"area": area, "space": space, "screen": screen} - + # Add window if screen differs from current context context = bpy.context if context and context.screen != screen: - window = next( - (w for w in context.window_manager.windows if w.screen == screen), - None - ) + window = next((w for w in context.window_manager.windows if w.screen == screen), None) if window: context_override["window"] = window - + return context_override @classmethod diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index 89603372d0..a15a3f8a29 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -1030,16 +1030,16 @@ class Loader(bonsai.core.tool.Loader): sense_factor = 1 else: return mesh - + if len(layer_set.MaterialLayers) == 1: return mesh - + bm = bmesh.new() bm.from_mesh(mesh) - + prev_co = None advance_direction = None # Will store direction to advance planes - + if not usage: sense_factor = 1 no = cls.get_extrusion_vector(element).normalized() @@ -1047,7 +1047,7 @@ class Loader(bonsai.core.tool.Loader): advance_direction = no elif usage.LayerSetDirection == "AXIS2": co = Vector((0.0, offset, 0.0)) - + # Get LOCAL extrusion direction local_extrusion = Vector([0.0, 0.0, 1.0]) if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): @@ -1057,14 +1057,14 @@ class Loader(bonsai.core.tool.Loader): if item.is_a("IfcExtrudedAreaSolid"): local_extrusion = Vector(item.ExtrudedDirection.DirectionRatios).normalized() break - + # Thickness direction: perpendicular to extrusion and length thickness_dir = local_extrusion.cross(Vector([1.0, 0.0, 0.0])).normalized() - + # Ensure it points in POSITIVE Y (through wall thickness, not backwards) if thickness_dir.y < 0: thickness_dir = -thickness_dir - + no = thickness_dir advance_direction = thickness_dir elif usage.LayerSetDirection == "AXIS3": @@ -1077,10 +1077,10 @@ class Loader(bonsai.core.tool.Loader): no = cls.get_extrusion_vector(element).normalized() no = Vector([1.0, 0.0, 0.0]) advance_direction = no - + no *= sense_factor advance_direction *= sense_factor - + # Cache this body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") styles = {} @@ -1088,25 +1088,25 @@ class Loader(bonsai.core.tool.Loader): for i, material in enumerate(mesh.materials): if style := tool.Ifc.get_entity(material): styles[style] = i - + last_i = len(layer_set.MaterialLayers) - 1 for i, layer in enumerate(layer_set.MaterialLayers): if i != last_i: prev_co = co.copy() # Use advance_direction (not no) to move planes! co += advance_direction * layer.LayerThickness * cls.unit_scale - + bisect_geom = bmesh.ops.bisect_plane( bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no ) bmesh.ops.duplicate(bm, geom=bisect_geom["geom_cut"]) - + if not (style := ifcopenshell.util.representation.get_material_style(layer.Material, body)): continue if (material_index := styles.get(style, None)) is None: material_index = len(mesh.materials) mesh.materials.append(tool.Ifc.get_object(style)) - + if i == last_i: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace): @@ -1139,14 +1139,14 @@ class Loader(bonsai.core.tool.Loader): item = item.FirstOperand if item.is_a("IfcExtrudedAreaSolid"): local_direction = Vector(item.ExtrudedDirection.DirectionRatios) - + # Transform to world coordinates using object rotation obj = tool.Ifc.get_object(element) if obj: # Apply object rotation to get actual world direction world_direction = obj.matrix_world.to_3x3() @ local_direction return world_direction - + return local_direction return Vector([0.0, 0.0, 1.0]) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 2250cbe9c0..e7da203a85 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -620,12 +620,11 @@ class Model(bonsai.core.tool.Model): if not openings[i].obj: openings.remove(i) - @classmethod def save_custom_offset_to_pset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: """Save custom offset settings to BBIM_MaterialLayer pset.""" props = tool.Material.get_object_material_props(obj) - + if not props.use_custom_offset: # Remove pset if custom offset is disabled pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") @@ -633,24 +632,24 @@ class Model(bonsai.core.tool.Model): pset_entity = tool.Ifc.get().by_id(pset["id"]) ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset_entity) return - + # Determine which reference to save based on usage type usage_type = tool.Model.get_usage_type(element) custom_wall_reference = None custom_slab_reference = None - + if usage_type == "LAYER2": custom_wall_reference = props.custom_wall_reference elif usage_type == "LAYER3": custom_slab_reference = props.custom_slab_reference - + # Get or create pset pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") if pset_data: pset = tool.Ifc.get().by_id(pset_data["id"]) else: pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_MaterialLayer") - + # Save properties (store in SI units) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) properties = { @@ -667,14 +666,14 @@ class Model(bonsai.core.tool.Model): pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") if not pset: return - + props = tool.Material.get_object_material_props(obj) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - + # Load properties props.use_custom_offset = pset.get("UseCustomOffset", False) props.custom_offset = pset.get("CustomOffset", 0.0) * unit_scale # Convert from SI - + # Load the appropriate reference based on usage type usage_type = tool.Model.get_usage_type(element) if usage_type == "LAYER2": @@ -718,14 +717,16 @@ class Model(bonsai.core.tool.Model): ) @classmethod - def get_material_layer_custom_offset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> Optional[float]: + def get_material_layer_custom_offset( + cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object + ) -> Optional[float]: """Get custom offset value, reading from pset if props are not set.""" unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) layer_params = tool.Model.get_material_layer_parameters(element) layer_offset = layer_params["offset"] thickness = layer_params["thickness"] / unit_scale props = tool.Material.get_object_material_props(obj) - + # Try to load from pset if not already in props if not props.use_custom_offset: pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer") @@ -733,7 +734,7 @@ class Model(bonsai.core.tool.Model): # Load from pset custom_offset = pset.get("CustomOffset", 0.0) usage_type = tool.Model.get_usage_type(element) - + if usage_type == "LAYER2": custom_offset_reference = pset.get("CustomWallReference", "CENTER") elif usage_type == "LAYER3": @@ -753,7 +754,7 @@ class Model(bonsai.core.tool.Model): return None direction_sense = layer_params["direction_sense"] - + if direction_sense == "POSITIVE" and custom_offset_reference in {"INTERIOR", "TOP"}: layer_offset = custom_offset - thickness * unit_scale if direction_sense == "POSITIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}: diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index fec4f6a42b..11c4bef3c9 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -529,26 +529,34 @@ class Project(bonsai.core.tool.Project): ifc_file = tool.Ifc.get() if not ifc_file: raise Exception("No IFC file loaded") - + doc = tool.Ifc.run("document.add_information", parent=None) - + if ifc_file.schema == "IFC2X3": - tool.Ifc.run("document.edit_information", information=doc, attributes={ - "DocumentId": "BLEND_METADATA", - "Name": "Blend Metadata", - "Scope": "BLEND_METADATA", - "Description": "References to blend metadata file for this IFC project", - "Location": metadata_filename - }) + tool.Ifc.run( + "document.edit_information", + information=doc, + attributes={ + "DocumentId": "BLEND_METADATA", + "Name": "Blend Metadata", + "Scope": "BLEND_METADATA", + "Description": "References to blend metadata file for this IFC project", + "Location": metadata_filename, + }, + ) else: - tool.Ifc.run("document.edit_information", information=doc, attributes={ - "Identification": "BLEND_METADATA", - "Name": "Blend Metadata", - "Scope": "BLEND_METADATA", - "Description": "References to blend metadata file for this IFC project", - "Location": metadata_filename - }) - + tool.Ifc.run( + "document.edit_information", + information=doc, + attributes={ + "Identification": "BLEND_METADATA", + "Name": "Blend Metadata", + "Scope": "BLEND_METADATA", + "Description": "References to blend metadata file for this IFC project", + "Location": metadata_filename, + }, + ) + return doc @classmethod @@ -556,14 +564,12 @@ class Project(bonsai.core.tool.Project): doc = cls.get_metadata_document_information() if not doc: return - + ifc_file = tool.Ifc.get() if not ifc_file: return - - tool.Ifc.run("document.edit_information", information=doc, attributes={ - "Location": metadata_filename - }) + + tool.Ifc.run("document.edit_information", information=doc, attributes={"Location": metadata_filename}) @classmethod def remove_metadata_document_information(cls) -> None: diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index dbfcc3cd3e..f7c9ce300b 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -166,11 +166,11 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t break if inches is None: inches = 0 - + # If feet is negative, inches should also be negative (subtractive) if feet < 0: inches = -inches - + # Convert to meters total_meters = (feet * 0.3048) + (inches * 0.0254) return total_meters diff --git a/src/bonsai/scripts/dev_environment_vscode_config.py b/src/bonsai/scripts/dev_environment_vscode_config.py index e1643b0aea..b574ba9bf4 100644 --- a/src/bonsai/scripts/dev_environment_vscode_config.py +++ b/src/bonsai/scripts/dev_environment_vscode_config.py @@ -1,4 +1,3 @@ - import bonsai, json, bpy from pathlib import Path @@ -11,14 +10,15 @@ settings_path = repo_root / ".vscode" / "settings.json" settings_path.parent.mkdir(parents=True, exist_ok=True) settings = json.loads(settings_path.read_text()) if settings_path.exists() else {} -settings.update({ - "bonsai.localRoot": repo_path.as_posix(), - "bonsai.remoteRoot": install_path.as_posix(), - "bonsai.blenderPath": Path(bpy.app.binary_path).parent.as_posix(), -}) +settings.update( + { + "bonsai.localRoot": repo_path.as_posix(), + "bonsai.remoteRoot": install_path.as_posix(), + "bonsai.blenderPath": Path(bpy.app.binary_path).parent.as_posix(), + } +) json_data = json.dumps(settings, indent=2) settings_path.write_text(json_data) print("\n\nBonsai/VSCode development environment configured successfully!\n\n") - diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py index 28025c6624..d3a6daaad6 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py @@ -107,23 +107,20 @@ class Usecase: for p in self.polyline ] else: - points = [ - (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1])) - for p in self.polyline - ] - + points = [(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1])) for p in self.polyline] + if self.file.schema == "IFC2X3": curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points]) else: curve = self.file.createIfcIndexedPolyCurve(self.file.createIfcCartesianPointList2D(points)) - + if self.x_angle: direction_ratios = (0.0, sin(self.x_angle), cos(self.x_angle)) else: direction_ratios = (0.0, 0.0, 1.0) extrusion_direction = self.file.createIfcDirection(direction_ratios) - + # Calculate depth based on extrusion angle extrusion_angle = abs(self.x_angle) if self.x_angle else 0 if extrusion_angle > 1e-6: @@ -132,7 +129,7 @@ class Usecase: else: perpendicular_depth = self.convert_si_to_unit(self.depth) perpendicular_offset = self.convert_si_to_unit(self.offset) - + position = None if self.file.schema == "IFC2X3" or self.offset != 0: position_vector = ( diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index e14b82c47d..72ff60c0a1 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -212,7 +212,7 @@ class FormatTransformer(lark.Transformer): """Handle variable substitution like {{z}} or {{Pset_Wall.FireRating}}""" if self.element is None: return "0" # Default value if no element context - + query_path = args[0] try: value = get_element_value(self.element, query_path) @@ -399,11 +399,11 @@ class GetElementTransformer(lark.Transformer): def format(query: str, element: Optional[ifcopenshell.entity_instance] = None) -> str: """Format a query string with optional element context for variable substitution. - + :param query: Format query string (can include {{variable}} placeholders) :param element: Optional IFC element for variable substitution :return: Formatted string - + Example: format("{{z}} / 2", element) # Substitutes element's z value format("imperial_length({{z}} / 2, 4)", element) # Uses z in calculation @@ -1257,4 +1257,4 @@ class FacetTransformer(lark.Transformer): if comparison.startswith("!"): return not result - return result \ No newline at end of file + return result