diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml index 4185574b2d..eea09da5a7 100644 --- a/.github/workflows/ci-bonsai-daily.yml +++ b/.github/workflows/ci-bonsai-daily.yml @@ -104,7 +104,7 @@ jobs: # Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo. # Download Blender. - wget -q -O blender.tar.xz https://download.blender.org/release/Blender4.5/blender-4.5.0-linux-x64.tar.xz + wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.0.1-linux-x64.tar.xz tar -xf blender.tar.xz # Setup Blender. diff --git a/.gitignore b/.gitignore index bfdfdd5f76..bee04658cd 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ venv # Visual Studio Code files .vscode +!.vscode/launch.json +!.vscode/tasks.json .vs # PyCharm files diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..63fa0f352d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Remote Attach", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${config:bonsai.localRoot}", + "remoteRoot": "${config:bonsai.remoteRoot}" + } + ] + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000..883373e476 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,53 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Configure bonsai/vscode development environment", + "type": "shell", + "command": "${input:blenderPath}", + "args": [ + "--background", + "--python", "${workspaceFolder}/src/bonsai/scripts/dev_environment_vscode_config.py" + ], + "problemMatcher": [] + }, + { + "label": "Launch blender with debugpy", + "type": "shell", + "command": "blender", + "options": { + "cwd": "${config:bonsai.blenderPath}" + }, + "args": [ + "--python-expr", + "import debugpy; debugpy.listen(5678)" + ], + "problemMatcher": [] + }, + { + "label": "Install debugpy in Blender", + "type": "shell", + "command": "blender", + "options": { + "cwd": "${config:bonsai.blenderPath}" + }, + "args": [ + "--background", + "--python-expr", + "import os, sys, subprocess; path=os.path.abspath(sys.executable); subprocess.call([path, '-m', 'ensurepip']); subprocess.call([path, '-m', 'pip', 'install', '--upgrade', 'debugpy'])" + ], + "problemMatcher": [] + } + + ], + "inputs": [ + { + "id": "blenderPath", + "type": "promptString", + "description": "Enter the path to the blender executable", + "default": "blender" + } + ] +} \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 785d7eac66..a7dc729881 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -130,12 +130,7 @@ classes = [ prop.StrProperty, operator.BIM_OT_enum_property_search, # /!\ Register AFTER prop.StrProperty operator.BIM_OT_attribute_search_values, - operator.BIM_UL_tab_panels, - operator.BIM_OT_toggle_panel_visibility, - operator.BIM_OT_bookmark_panel, - operator.BIM_OT_manage_tab_panels, operator.BIM_OT_manage_tab_visibility, - operator.BIM_OT_toggle_tab_visibility, operator.BIM_OT_reset_ui_layout, prop.ObjProperty, prop.MultipleFileSelect, @@ -144,7 +139,7 @@ classes = [ prop.BIMAreaProperties, prop.BIMTabProperties, prop.BIMTabVisibility, # Must be registered before BIMProperties - prop.BIMPanelProperties, # Must be registered before BIMProperties + prop.BIMPanelVisibility, # Must be registered before BIMProperties prop.BIMProperties, prop.IfcParameter, prop.PsetQto, @@ -157,6 +152,8 @@ classes = [ prop.BIMSnapGroups, ui.BIM_UL_clipping_plane, ui.BIM_UL_generic, + ui.BIM_UL_tab_visibilities, + ui.BIM_UL_panel_visibilities, ui.DocPreferences, ui.GizmoPreferencesDoor, # Register before GizmoPreferences ui.GizmoPreferencesWindow, # Register before GizmoPreferences @@ -318,10 +315,6 @@ def register(): # RestrictedContext doesn't allow accessing scene attribute, postpone it for a bit. bpy.app.timers.register(tool.Blender.setup_user_data_dir, first_interval=0.1) - bpy.types.Scene.active_tab_name = bpy.props.StringProperty() - bpy.types.Scene.tab_panels = bpy.props.CollectionProperty(type=bpy.types.PropertyGroup) - bpy.types.Scene.active_tab_panel_index = bpy.props.IntProperty() - def unregister(): global icons @@ -363,7 +356,3 @@ def unregister(): tool.Blender.remove_scene_panel_override(panel) bpy.app.translations.unregister("bonsai") - - del bpy.types.Scene.active_tab_name - del bpy.types.Scene.tab_panels - del bpy.types.Scene.active_tab_panel_index diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index 3b3dd502e4..1a92078be3 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -428,7 +428,11 @@ def get_enum_items( else: annotations_data = data - prop = annotations_data.__annotations__[prop_name] + try: + annotations = annotations_data.__annotations__ + except AttributeError: + annotations = type(annotations_data).__annotations__ + prop = annotations[prop_name] items = prop.keywords.get("items") if items is None: return @@ -788,149 +792,3 @@ def draw_filter( op.group_index = i op.index = j op.module = module - - -# ============================================================================ -# UI Panel Visibility Helpers -# ============================================================================ - - -def get_tab_names(): - from bonsai.bim.prop import get_tab - - enum_items = get_tab(None, None) - # Exclude None separators and the BLENDER tab (not part of BIM tab system) - return [item[0] for item in enum_items if item is not None and item[0] != "BLENDER"] - - -def get_panel_tab_name(panel_class): - if hasattr(panel_class, "bim_tab_name"): - return panel_class.bim_tab_name - return "PROJECT" # Default fallback - - -def should_show_panel(panel_id, panel_tab_name, context): - if tool.Blender.is_tab(context, "BOOKMARK"): - return is_panel_bookmarked(panel_id) and get_panel_visibility(panel_id, "BOOKMARK") - - if tool.Blender.is_tab(context, panel_tab_name): - return get_tab_visibility(panel_tab_name) and get_panel_visibility(panel_id, panel_tab_name) - - return False - - -def get_tab_visibility(tab_name): - bim_props = tool.Blender.get_bim_props() - tab_vis = bim_props.tab_visibilities.get(tab_name) - return tab_vis.is_visible if tab_vis else True - - -def set_tab_visibility(tab_name, visible): - bim_props = tool.Blender.get_bim_props() - tab_vis = bim_props.tab_visibilities.get(tab_name) - if tab_vis: - tab_vis.is_visible = visible - else: - new_tab = bim_props.tab_visibilities.add() - new_tab.name = tab_name - new_tab.is_visible = visible - - -def get_panel_visibility(panel_id, current_tab=None): - panel_config = get_panel_config(panel_id) - if panel_config: - if current_tab == "BOOKMARK": - return panel_config.is_visible_in_bookmarks - else: - return panel_config.is_visible_in_tab - return True - - -def is_panel_bookmarked(panel_id): - panel_config = get_panel_config(panel_id) - if panel_config: - return panel_config.is_bookmarked - return False - - -def get_panel_config(panel_id, create_if_missing=False): - try: - bim_props = tool.Blender.get_bim_props() - except (AttributeError, AssertionError): - return None - - for prop in bim_props.panel_properties: - if prop.name == panel_id: - return prop - - if create_if_missing: - try: - prop = bim_props.panel_properties.add() - prop.name = panel_id - prop.is_visible_in_tab = True - prop.is_visible_in_bookmarks = True - prop.is_bookmarked = False - return prop - except AttributeError: - pass - - return None - - -def get_all_tab_panels(force_refresh=False): - panels = {tab_name: [] for tab_name in get_tab_names() if tab_name != "BOOKMARK"} - panels["BOOKMARK"] = [] - - bim_props = tool.Blender.get_bim_props() - for prop in bim_props.panel_properties: - panel_class = getattr(bpy.types, prop.name, None) - if panel_class: - tab_name = get_panel_tab_name(panel_class) - if tab_name and tab_name != "BOOKMARK": - bl_label = getattr(panel_class, "bl_label", prop.name) - panels[tab_name].append({"bl_idname": prop.name, "bl_label": bl_label}) - - if prop.is_bookmarked: - panel_class = getattr(bpy.types, prop.name, None) - if panel_class: - bl_label = getattr(panel_class, "bl_label", prop.name) - panels["BOOKMARK"].append({"bl_idname": prop.name, "bl_label": bl_label}) - - if not panels["BOOKMARK"]: - panels["BOOKMARK"] = [{}] - - return panels - - -def initialize_tab_visibilities(): - bim_props = tool.Blender.get_bim_props() - - if len(bim_props.tab_visibilities) > 0: - return - - for tab_name in get_tab_names(): - tab_vis = bim_props.tab_visibilities.add() - tab_vis.name = tab_name - tab_vis.is_visible = True - - -def initialize_panel_properties(): - - bim_props = tool.Blender.get_bim_props() - - if len(bim_props.panel_properties) > 0: - return - - for attr_name in dir(bpy.types): - if attr_name.startswith("BIM_PT_tab_"): - panel_class = getattr(bpy.types, attr_name) - if not hasattr(panel_class, "bl_idname"): - continue - - panel_id = panel_class.bl_idname - - prop = bim_props.panel_properties.add() - prop.name = panel_id - prop.is_visible_in_tab = True - prop.is_visible_in_bookmarks = True - prop.is_bookmarked = False 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/document/__init__.py b/src/bonsai/bonsai/bim/module/document/__init__.py index f4eede1721..cc71d16306 100644 --- a/src/bonsai/bonsai/bim/module/document/__init__.py +++ b/src/bonsai/bonsai/bim/module/document/__init__.py @@ -24,26 +24,33 @@ classes = ( operator.AddInformation, operator.AssignDocument, operator.DisableDocumentEditingUI, + operator.DisableObjectDocumentEditingUI, operator.DisableEditingDocument, operator.EditDocument, operator.EnableEditingDocument, - operator.LoadDocument, - operator.LoadParentDocument, + operator.LoadObjectDocuments, operator.LoadProjectDocuments, operator.RemoveDocument, operator.SelectDocumentObjects, + operator.ToggleDocument, operator.UnassignDocument, + operator.OpenIFCDocument, prop.Document, + prop.DocumentObject, prop.BIMDocumentProperties, ui.BIM_PT_documents, ui.BIM_PT_object_documents, ui.BIM_UL_documents, + ui.BIM_UL_document_objects, + ui.BIM_MT_object_documents_context_menu, ) def register(): bpy.types.Scene.BIMDocumentProperties = bpy.props.PointerProperty(type=prop.BIMDocumentProperties) + bpy.types.VIEW3D_MT_object_context_menu.append(ui.add_object_documents_context_menu) def unregister(): del bpy.types.Scene.BIMDocumentProperties + bpy.types.VIEW3D_MT_object_context_menu.remove(ui.add_object_documents_context_menu) diff --git a/src/bonsai/bonsai/bim/module/document/data.py b/src/bonsai/bonsai/bim/module/document/data.py index 5cde82e499..b68c118e56 100644 --- a/src/bonsai/bonsai/bim/module/document/data.py +++ b/src/bonsai/bonsai/bim/module/document/data.py @@ -21,6 +21,7 @@ import bpy import ifcopenshell import ifcopenshell.util.schema import bonsai.tool as tool +from natsort import natsorted def refresh(): @@ -35,30 +36,50 @@ class DocumentData: @classmethod def load(cls): cls.data = { - "total_information": cls.total_information(), - "parent_document": cls.parent_document(), + "total_documents": cls.total_documents(), + "document_objects": cls.document_objects(), } cls.is_loaded = True @classmethod - def total_information(cls): - return len( - [ - rel - for rel in tool.Ifc.get().by_type("IfcProject")[0].HasAssociations or [] - if rel.is_a("IfcRelAssociatesDocument") and rel.RelatingDocument.is_a("IfcDocumentInformation") - ] - ) + def total_documents(cls): + file = tool.Ifc.get() + return len(file.by_type("IfcDocumentInformation")) + len(file.by_type("IfcDocumentReference")) @classmethod - def parent_document(cls): + def document_objects(cls): + document_objects = {} + file = tool.Ifc.get() + + for rel in file.by_type("IfcRelAssociatesDocument"): + document_id = rel.RelatingDocument.id() + if document_id not in document_objects: + document_objects[document_id] = [] + + for related_object in rel.RelatedObjects: + element = related_object + obj = tool.Ifc.get_object(element) + if obj: + document_objects[document_id].append({"id": element.id(), "name": obj.name, "obj": obj}) + + return document_objects + + @classmethod + def load_document_objects_into_props(cls, document_id): + if not cls.is_loaded: + cls.load() + props = tool.Document.get_document_props() - if len(props.breadcrumbs): - parent = tool.Ifc.get().by_id(int(props.breadcrumbs[-1].name)) - if tool.Ifc.get_schema() == "IFC2X3": - return str(parent.DocumentId) - return str(parent.Identification) - return "" + props.document_objects.clear() + + if document_id not in cls.data["document_objects"]: + return + + sorted_objects = natsorted(cls.data["document_objects"][document_id], key=lambda x: x["name"].lower()) + + for obj_data in sorted_objects: + item = props.document_objects.add() + item.name = obj_data["name"] class ObjectDocumentData: @@ -72,6 +93,18 @@ class ObjectDocumentData: } cls.is_loaded = True + @staticmethod + def convert_to_file_uri(location: str) -> str: + if not location: + return "" + + uri = location + if "://" not in uri: + if not os.path.isabs(uri): + uri = os.path.abspath(os.path.join(os.path.dirname(tool.Ifc.get_path()), uri)) + uri = "file://" + uri + return uri + @classmethod def documents(cls): results = [] @@ -80,44 +113,61 @@ class ObjectDocumentData: return results for rel in getattr(element, "HasAssociations", []): if rel.is_a("IfcRelAssociatesDocument"): - if not rel.RelatingDocument.is_a("IfcDocumentReference"): + relating_document = rel.RelatingDocument + + is_information = relating_document.is_a("IfcDocumentInformation") + is_reference = relating_document.is_a("IfcDocumentReference") + + if not (is_information or is_reference): continue - name = rel.RelatingDocument.Name + name = relating_document.Name - if tool.Ifc.get_schema() == "IFC2X3": - if not name and rel.RelatingDocument.ReferenceToDocument: - name = rel.RelatingDocument.ReferenceToDocument[0].Name + location = None + identification = None - identification = rel.RelatingDocument.ItemReference - if not identification and rel.RelatingDocument.ReferenceToDocument: - identification = rel.RelatingDocument.ReferenceToDocument[0].DocumentId + if is_information: + if tool.Ifc.get_schema() == "IFC2X3": + identification = relating_document.DocumentId + else: + identification = relating_document.Identification - location = rel.RelatingDocument.Location + location = getattr(relating_document, "Location", None) + description = getattr(relating_document, "Description", "No description") else: - if not name and rel.RelatingDocument.ReferencedDocument: - name = rel.RelatingDocument.ReferencedDocument.Name + description = relating_document.Description + if tool.Ifc.get_schema() == "IFC2X3": + reference_to_document = relating_document.ReferenceToDocument + if not name and reference_to_document: + name = reference_to_document[0].Name - identification = rel.RelatingDocument.Identification - if not identification and rel.RelatingDocument.ReferencedDocument: - identification = rel.RelatingDocument.ReferencedDocument.Identification + identification = relating_document.ItemReference + if not identification and reference_to_document: + identification = reference_to_document[0].DocumentId + location = relating_document.Location + else: + referenced_document = relating_document.ReferencedDocument + if not name and referenced_document: + name = referenced_document.Name - location = rel.RelatingDocument.Location - if location is None and rel.RelatingDocument.ReferencedDocument: - location = rel.RelatingDocument.ReferencedDocument.Location + identification = relating_document.Identification + if not identification and referenced_document: + identification = referenced_document.Identification - if location: - if not "://" in location: - if not os.path.isabs(location): - location = os.path.abspath(os.path.join(os.path.dirname(tool.Ifc.get_path()), location)) - location = "file://" + location + location = relating_document.Location + if location is None and referenced_document: + location = referenced_document.Location + + location = cls.convert_to_file_uri(location) if location else None results.append( { - "id": rel.RelatingDocument.id(), + "id": relating_document.id(), "identification": identification, "name": name, "location": location, + "is_information": is_information, + "description": description, } ) return results diff --git a/src/bonsai/bonsai/bim/module/document/operator.py b/src/bonsai/bonsai/bim/module/document/operator.py index da75e16e63..447d3b1693 100644 --- a/src/bonsai/bonsai/bim/module/document/operator.py +++ b/src/bonsai/bonsai/bim/module/document/operator.py @@ -18,12 +18,10 @@ import bpy import json -import ifcopenshell.api -import ifcopenshell.util.attribute -import ifcopenshell.util.element import bonsai.bim.handler import bonsai.tool as tool import bonsai.core.document as core +from bonsai.bim.module.document.data import DocumentData, ObjectDocumentData class LoadProjectDocuments(bpy.types.Operator): @@ -33,30 +31,6 @@ class LoadProjectDocuments(bpy.types.Operator): def execute(self, context): core.load_project_documents(tool.Document) - bonsai.bim.handler.refresh_ui_data() # Update breadcrumbs data. - return {"FINISHED"} - - -class LoadDocument(bpy.types.Operator): - bl_idname = "bim.load_document" - bl_label = "Load Document" - bl_options = {"REGISTER", "UNDO"} - document: bpy.props.IntProperty() - - def execute(self, context): - core.load_document(tool.Document, document=tool.Ifc.get().by_id(self.document)) - bonsai.bim.handler.refresh_ui_data() # Update breadcrumbs data. - return {"FINISHED"} - - -class LoadParentDocument(bpy.types.Operator): - bl_idname = "bim.load_parent_document" - bl_label = "Load Parent Document" - bl_options = {"REGISTER", "UNDO"} - - def execute(self, context): - core.load_parent_document(tool.Document) - bonsai.bim.handler.refresh_ui_data() # Update breadcrumbs data. return {"FINISHED"} @@ -70,6 +44,16 @@ class DisableDocumentEditingUI(bpy.types.Operator): return {"FINISHED"} +class DisableObjectDocumentEditingUI(bpy.types.Operator): + bl_idname = "bim.disable_object_document_editing_ui" + bl_label = "Disable Object Document Editing UI" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + core.disable_object_document_editing_ui(tool.Document) + return {"FINISHED"} + + class EnableEditingDocument(bpy.types.Operator): bl_idname = "bim.enable_editing_document" bl_label = "Enable Editing Document" @@ -77,7 +61,7 @@ class EnableEditingDocument(bpy.types.Operator): document: bpy.props.IntProperty() def execute(self, context): - core.enable_editing_document(tool.Document, document=tool.Ifc.get().by_id(self.document)) + core.enable_editing_document(tool.Document, ifc_document=tool.Ifc.get().by_id(self.document)) return {"FINISHED"} @@ -97,7 +81,35 @@ class AddInformation(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - core.add_information(tool.Ifc, tool.Document) + props = tool.Document.get_document_props() + parent = None + if props.active_document: + selected_document = props.active_document + + if selected_document.document_type == "PROJECT": + parent = tool.Ifc.get().by_type("IfcProject")[0] + elif selected_document.document_type == "INFORMATION": + parent = tool.Ifc.get().by_id(selected_document.ifc_definition_id) + elif selected_document.document_type == "REFERENCE": + self.report({"ERROR"}, "Cannot add an information element as a child of a reference element") + return {"CANCELLED"} + else: + parent = tool.Ifc.get().by_type("IfcProject")[0] + + core.add_information(tool.Ifc, tool.Document, parent) + + expanded_docs = [] + try: + expanded_docs = json.loads(props.json_string) + except (AttributeError, json.JSONDecodeError): + pass + + if parent and parent.is_a("IfcDocumentInformation"): + if parent.id() not in expanded_docs: + expanded_docs.append(parent.id()) + + props.json_string = json.dumps(expanded_docs) + bpy.ops.bim.load_project_documents() class AddDocumentReference(bpy.types.Operator, tool.Ifc.Operator): @@ -106,7 +118,33 @@ class AddDocumentReference(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): + props = tool.Document.get_document_props() + + if not props.active_document: + self.report({"ERROR"}, "No document selected") + return {"CANCELLED"} + + selected_document = props.active_document + + if selected_document.document_type != "INFORMATION": + self.report({"ERROR"}, "Cannot add a reference to a document that is not an information element") + return {"CANCELLED"} + + parent = tool.Ifc.get().by_id(selected_document.ifc_definition_id) + + props.document_attributes.clear() core.add_reference(tool.Ifc, tool.Document) + expanded_docs = [] + try: + expanded_docs = json.loads(props.json_string) + except (AttributeError, json.JSONDecodeError): + pass + + if parent.id() not in expanded_docs: + expanded_docs.append(parent.id()) + props.json_string = json.dumps(expanded_docs) + + bpy.ops.bim.load_project_documents() class EditDocument(bpy.types.Operator, tool.Ifc.Operator): @@ -116,7 +154,9 @@ class EditDocument(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): props = tool.Document.get_document_props() - core.edit_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(props.active_document_id)) + if props.active_document_id: + core.edit_document(tool.Ifc, tool.Document, ifc_document=tool.Ifc.get().by_id(props.active_document_id)) + props.active_document_id = 0 class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator): @@ -126,7 +166,7 @@ class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator): document: bpy.props.IntProperty() def _execute(self, context): - core.remove_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(self.document)) + core.remove_document(tool.Ifc, tool.Document, ifc_document=tool.Ifc.get().by_id(self.document)) class AssignDocument(bpy.types.Operator, tool.Ifc.Operator): @@ -138,12 +178,15 @@ class AssignDocument(bpy.types.Operator, tool.Ifc.Operator): document: bpy.props.IntProperty() def _execute(self, context): - document = tool.Ifc.get().by_id(self.document) objs = [bpy.data.objects[self.obj]] if self.obj else tool.Blender.get_selected_objects() for obj in objs: element = tool.Ifc.get_entity(obj) if element: - core.assign_document(tool.Ifc, product=element, document=document) + core.assign_document(tool.Ifc, product=element, ifc_document=tool.Ifc.get().by_id(self.document)) + + tool.Document.update_document_objects(self.document) + ObjectDocumentData.load() + return {"FINISHED"} class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator): @@ -154,12 +197,25 @@ class UnassignDocument(bpy.types.Operator, tool.Ifc.Operator): document: bpy.props.IntProperty() def _execute(self, context): - document = tool.Ifc.get().by_id(self.document) objs = [bpy.data.objects.get(self.obj)] if self.obj else tool.Blender.get_selected_objects() for obj in objs: - element = tool.Ifc.get_entity(obj) - if element: - core.unassign_document(tool.Ifc, product=element, document=document) + if obj: + element = tool.Ifc.get_entity(obj) + if element: + core.unassign_document(tool.Ifc, product=element, ifc_document=tool.Ifc.get().by_id(self.document)) + + props = tool.Document.get_document_props() + active_document_id = None + if props.active_document: + active_document_id = props.active_document.ifc_definition_id + + if active_document_id and active_document_id != self.document: + tool.Document.update_document_objects(active_document_id) + else: + tool.Document.update_document_objects() + + ObjectDocumentData.load() + return {"FINISHED"} class SelectDocumentObjects(bpy.types.Operator): @@ -182,3 +238,81 @@ class SelectDocumentObjects(bpy.types.Operator): i += 1 self.report({"INFO"}, f"{i} objects selected.") return {"FINISHED"} + + +class LoadObjectDocuments(bpy.types.Operator): + bl_idname = "bim.load_object_documents" + bl_label = "Load Object Documents" + bl_description = "Load documents to assign to the selected object" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + core.load_project_documents(tool.Document) + + props = tool.Document.get_document_props() + props.is_object_editing = True + ObjectDocumentData.load() + return {"FINISHED"} + + +class OpenIFCDocument(bpy.types.Operator): + bl_idname = "bim.open_ifc_document" + bl_label = "Open IFC Document" + bl_description = "Open the IFC document in a new Blender instance and load the project" + bl_options = {"REGISTER", "UNDO"} + + uri: bpy.props.StringProperty(name="URI") + + def execute(self, context): + import subprocess + import os + + if not self.uri or not self.uri.lower().startswith("file://"): + self.report({"ERROR"}, "Only local file:// URIs are supported") + return {"CANCELLED"} + + filepath = self.uri[7:] + + if not os.path.exists(filepath): + self.report({"ERROR"}, f"File not found: {filepath}") + return {"CANCELLED"} + + blender_path = bpy.app.binary_path + args = [ + blender_path, + "--python-expr", + "import bpy; bpy.ops.bim.load_project(filepath='{}')".format(filepath), + ] + subprocess.Popen(args) + self.report({"INFO"}, f"Opening {filepath} in a new Blender instance") + + return {"FINISHED"} + + +class ToggleDocument(bpy.types.Operator): + bl_idname = "bim.toggle_document" + bl_label = "Toggle Document" + bl_options = {"REGISTER", "UNDO"} + document: bpy.props.IntProperty() + option: bpy.props.StringProperty() + + def execute(self, context): + expanded_documents = [] + props = tool.Document.get_document_props() + try: + expanded_documents = json.loads(props.json_string) + except (AttributeError, json.JSONDecodeError): + expanded_documents = [] + + document_id = self.document + + document = tool.Ifc.get().by_id(document_id) + if document: + if self.option == "Expand" and document_id not in expanded_documents: + expanded_documents.append(document_id) + elif self.option == "Collapse" and document_id in expanded_documents: + expanded_documents.remove(document_id) + + props.json_string = json.dumps(expanded_documents) + bpy.ops.bim.load_project_documents() + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/document/prop.py b/src/bonsai/bonsai/bim/module/document/prop.py index f4e4b16686..93369b546d 100644 --- a/src/bonsai/bonsai/bim/module/document/prop.py +++ b/src/bonsai/bonsai/bim/module/document/prop.py @@ -1,24 +1,7 @@ -# Bonsai - OpenBIM Blender Add-on -# Copyright (C) 2020, 2021 Dion Moult -# -# This file is part of Bonsai. -# -# Bonsai is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Bonsai 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 General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Bonsai. If not, see . - import bpy import bonsai.tool as tool from bonsai.bim.prop import StrProperty, Attribute +from bonsai.bim.module.document.data import refresh from bpy.types import PropertyGroup from bpy.props import ( PointerProperty, @@ -30,6 +13,7 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from bonsai.bim.module.document.data import DocumentData from typing import TYPE_CHECKING, Union @@ -49,18 +33,50 @@ def update_document_identification(self: "Document", context: bpy.types.Context) tool.Document.set_external_reference_id(document, self.identification) +def update_active_document_index(self, context): + refresh() + if document := self.active_document: + if document.ifc_definition_id: + DocumentData.load_document_objects_into_props(document.ifc_definition_id) + + class Document(PropertyGroup): - name: StringProperty(name="Name", update=update_document_name) - identification: StringProperty(name="Identification", update=update_document_identification) - is_information: BoolProperty( - name="Is Information", - description="Whether element is IfcDocumentInformation, otherwise it's IfcDocumentReference.", + name: StringProperty(name="Name") + identification: StringProperty(name="Identification") + description: StringProperty(name="Description") + ifc_definition_id: IntProperty(name="IFC Definition ID") + location: StringProperty(name="Location", default="") + tree_depth: IntProperty(name="Tree Depth", default=0) + has_children: BoolProperty(name="Has Children", default=False) + is_expanded: BoolProperty(name="Is Expanded", default=False) + document_type: EnumProperty( + name="Document Type", + items=[ + ("PROJECT", "Project", "Virtual project root node"), + ("INFORMATION", "Information", "IfcDocumentInformation"), + ("REFERENCE", "Reference", "IfcDocumentReference"), + ], + default="INFORMATION", ) + + if TYPE_CHECKING: + name: str + identification: str + description: str + ifc_definition_id: int + location: str + tree_depth: int + has_children: bool + is_expanded: bool + document_type: str + + +class DocumentObject(PropertyGroup): + name: StringProperty(name="Name") ifc_definition_id: IntProperty(name="IFC Definition ID") if TYPE_CHECKING: - identification: str - is_information: bool + name: str ifc_definition_id: int @@ -68,17 +84,23 @@ class BIMDocumentProperties(PropertyGroup): document_attributes: CollectionProperty(name="Document Attributes", type=Attribute) active_document_id: IntProperty(name="Active Document Id") documents: CollectionProperty(name="Documents", type=Document) - breadcrumbs: CollectionProperty(name="Breadcrumbs", type=StrProperty) - active_document_index: IntProperty(name="Active Document Index") + active_document_index: IntProperty(name="Active Document Index", update=update_active_document_index) is_editing: BoolProperty(name="Is Editing", default=False) + is_object_editing: BoolProperty(name="Is Object Editing", default=False) + document_objects: CollectionProperty(name="Document Objects", type=DocumentObject) + active_document_object_index: IntProperty(name="Active Document Object Index") + json_string: StringProperty(name="JSON String", default="[]") if TYPE_CHECKING: document_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] active_document_id: int documents: bpy.types.bpy_prop_collection_idprop[Document] - breadcrumbs: bpy.types.bpy_prop_collection_idprop[StrProperty] active_document_index: int is_editing: bool + is_object_editing: bool + document_objects: bpy.types.bpy_prop_collection_idprop[DocumentObject] + active_document_object_index: int + json_string: str @property def active_document(self) -> Union[Document, None]: diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py index 5897edbad8..c382f1c721 100644 --- a/src/bonsai/bonsai/bim/module/document/ui.py +++ b/src/bonsai/bonsai/bim/module/document/ui.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import bpy import bonsai.tool as tool from bpy.types import Panel, UIList from bonsai.bim.helper import draw_attributes @@ -42,7 +43,8 @@ class BIM_PT_documents(Panel): self.props = tool.Document.get_document_props() row = self.layout.row(align=True) - row.label(text="{} Documents Found".format(DocumentData.data["total_information"]), icon="FILE") + row.label(text="{} Documents found".format(DocumentData.data["total_documents"]), icon="FILE") + if self.props.is_editing: row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") else: @@ -52,34 +54,54 @@ class BIM_PT_documents(Panel): return row = self.layout.row(align=True) - if self.props.breadcrumbs: - row.operator("bim.load_parent_document", text="", icon="FRAME_PREV") - row.label(text=DocumentData.data["parent_document"]) - else: - row.alignment = "RIGHT" - row.operator("bim.add_information", text="", icon="ADD") - if self.props.breadcrumbs: - row.operator("bim.add_document_reference", text="", icon="FILE_HIDDEN") + row.alignment = "RIGHT" - active_document = self.props.active_document - - if self.props.active_document_id: + if self.props.active_document_id > 0: row.operator("bim.edit_document", text="", icon="CHECKMARK") row.operator("bim.disable_editing_document", text="", icon="CANCEL") - elif active_document: - ifc_definition_id = active_document.ifc_definition_id - row.operator("bim.select_document_objects", text="", icon="RESTRICT_SELECT_OFF").document = ( - ifc_definition_id - ) - row.operator("bim.assign_document", text="", icon="BRUSH_DATA").document = ifc_definition_id - row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ifc_definition_id - row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id + else: + if not self.props.active_document or self.props.active_document.document_type in ["INFORMATION", "PROJECT"]: + row.operator("bim.add_information", text="", icon="ADD") + if self.props.active_document and ( + self.props.active_document.document_type == "INFORMATION" + and self.props.active_document.document_type != "PROJECT" + ): + row.operator("bim.add_document_reference", text="", icon="FILE_HIDDEN") + + active_document = self.props.active_document + if active_document: + ifc_definition_id = active_document.ifc_definition_id + + if active_document.document_type != "PROJECT": + row.operator("bim.select_document_objects", text="", icon="RESTRICT_SELECT_OFF").document = ( + ifc_definition_id + ) + row.operator("bim.assign_document", text="", icon="BRUSH_DATA").document = ifc_definition_id + row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ( + ifc_definition_id + ) + row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id self.layout.template_list("BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index") - if self.props.active_document_id: + if self.props.active_document_id > 0: + active_document = self.props.active_document draw_attributes(self.props.document_attributes, self.layout) + if self.props.is_editing and self.props.active_document: + document = self.props.active_document + box = self.layout.box() + row = box.row(align=True) + row.label(text="Assigned Objects", icon="OUTLINER_OB_EMPTY") + box.template_list( + "BIM_UL_document_objects", + "", + self.props, + "document_objects", + self.props, + "active_document_object_index", + ) + class BIM_PT_object_documents(Panel): bl_label = "Documents" @@ -102,65 +124,209 @@ class BIM_PT_object_documents(Panel): return True def draw(self, context): + obj = context.active_object if not ObjectDocumentData.is_loaded: ObjectDocumentData.load() - obj = context.active_object self.oprops = tool.Blender.get_object_bim_props(obj) self.props = tool.Document.get_document_props() self.file = tool.Ifc.get() - self.draw_add_ui() - - if not ObjectDocumentData.data["documents"]: - row = self.layout.row(align=True) - row.label(text="No Documents", icon="FILE") - - for document in ObjectDocumentData.data["documents"]: - row = self.layout.row(align=True) - row.label(text=document["identification"] or "*", icon="FILE") - row.label(text=document["name"] or "Unnamed") - if document["location"]: - row.operator("bim.open_uri", icon="URL", text="").uri = document["location"] - row.operator("bim.unassign_document", text="", icon="X").document = document["id"] - - def draw_add_ui(self): - if not self.props.is_editing: - row = self.layout.row(align=True) - row.operator("bim.load_project_documents", text="Assign Document References", icon="ADD") - return + doc_count = len(ObjectDocumentData.data["documents"]) row = self.layout.row(align=True) - if self.props.breadcrumbs: - row.operator("bim.load_parent_document", text="", icon="FRAME_PREV") - row.label(text=DocumentData.data["parent_document"]) + row.label(text="{} Documents Assigned".format(doc_count), icon="FILE") + + if self.props.is_object_editing: + row.operator("bim.disable_object_document_editing_ui", text="", icon="CANCEL") else: + row.operator("bim.load_object_documents", text="", icon="IMPORT") + + if not self.props.is_object_editing and doc_count == 0: + row = self.layout.row() + row.label(text="No documents assigned", icon="INFO") + return + + if self.props.is_object_editing: + self.draw_add_ui() + box = self.layout.box() + row = box.row(align=True) + row.label(text="Assigned Documents", icon="OUTLINER_OB_EMPTY") + + if doc_count > 0: + col = box.column(align=True) + for document in ObjectDocumentData.data["documents"]: + row = col.row(align=True) + + # Create a split layout to separate left and right sides + split = row.split(factor=0.7) # Adjust factor as needed (0.7 = 70% left, 30% right) + + # Left side - Document identification and name + left_side = split.row(align=True) + left_side.alignment = "LEFT" + left_side.label(text=document["identification"] or "*", icon="FILE") + left_side.label(text=document["name"] or "Unnamed") + + # Right side - Action buttons + right_side = split.row(align=True) + right_side.alignment = "RIGHT" # Align buttons to the right + + if document["location"]: + if document["location"].lower().endswith(".ifc"): + right_side.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = document[ + "location" + ] + right_side.operator("bim.open_uri", icon="URL", text="").uri = document["location"] + + right_side.operator("bim.unassign_document", text="", icon="X").document = document["id"] + + def draw_add_ui(self): + if self.props.is_object_editing: + row = self.layout.row(align=True) row.alignment = "RIGHT" - if self.props.documents and self.props.active_document_index < len(self.props.documents): - document = self.props.documents[self.props.active_document_index] - if not document.is_information: - row.operator("bim.assign_document", text="", icon="ADD").document = document.ifc_definition_id - row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") + if self.props.active_document: + document = self.props.active_document - self.layout.template_list("BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index") + assigned_doc_ids = [] + for doc in ObjectDocumentData.data["documents"]: + assigned_doc_ids.append(doc["id"]) + + if ( + document.document_type == "INFORMATION" + and document.document_type != "PROJECT" + and document.ifc_definition_id not in assigned_doc_ids + ): + doc_op = row.operator("bim.assign_document", text="", icon="BRUSH_DATA") + doc_op.document = document.ifc_definition_id + elif document.ifc_definition_id in assigned_doc_ids: + row.label(text="", icon="CHECKMARK") + self.layout.template_list( + "BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index" + ) class BIM_UL_documents(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): if item: row = layout.row(align=True) + indent_depth = 0 - if item.is_information: - op = row.operator("bim.load_document", text="", emboss=False, icon="DISCLOSURE_TRI_RIGHT") - op.document = item.ifc_definition_id - row.label(text="", icon="FILE") - else: + if item.document_type != "PROJECT": + if item.tree_depth > 1: + indent_depth = item.tree_depth - 1 + + for i in range(indent_depth): row.label(text="", icon="BLANK1") - row.label(text="", icon="FILE_HIDDEN") + if item.document_type == "PROJECT": + row.label(text="", icon="OUTLINER_COLLECTION") + row.label(text=item.name) + return + + if item.document_type == "INFORMATION" and item.has_children: + op = row.operator( + "bim.toggle_document", icon="TRIA_DOWN" if item.is_expanded else "TRIA_RIGHT", text="", emboss=False + ) + op.document = item.ifc_definition_id + op.option = "Collapse" if item.is_expanded else "Expand" + elif item.document_type == "INFORMATION": + row.label(text="", icon="BLANK1") + + if item.document_type == "INFORMATION": + row.label(text="", icon="FILE") + text = " - ".join([x for x in [item.location, item.description, item.name] if x]) + else: + row.label(text="", icon="FILE_HIDDEN") + text = " - ".join([x for x in [item.location, item.description] if x]) split1 = row.split(factor=0.1) - # split1.label(text=item.identification) split1.prop(item, "identification", text="", emboss=False) - split2 = split1.split(factor=0.9) - split2.prop(item, "name", text="", emboss=False) + split2 = split1.split(factor=0.8) + split2.label(text=text) + + if item.location: + uri = ObjectDocumentData.convert_to_file_uri(item.location) + if item.location.lower().endswith(".ifc"): + row.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = uri + row.operator("bim.open_uri", icon="URL", text="").uri = uri + + +class BIM_UL_document_objects(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + if item: + row = layout.row(align=True) + row.prop(item, "name", text="", emboss=False, icon="OBJECT_DATA") + row.operator("bim.select_object", text="", icon="RESTRICT_SELECT_OFF").obj_name = item.name + + props = tool.Document.get_document_props() + if props.active_document: + document = props.active_document + + op = row.operator("bim.unassign_document", text="", icon="X") + op.document = document.ifc_definition_id + op.obj = item.name + + +def add_object_documents_context_menu(self, context): + if not context.active_object: + return + + if not tool.Blender.get_ifc_definition_id(context.active_object): + return + + self.layout.separator() + self.layout.menu("BIM_MT_object_documents_context_menu", icon="FILE") + + +class BIM_MT_object_documents_context_menu(bpy.types.Menu): + bl_idname = "BIM_MT_object_documents_context_menu" + bl_label = "Documents" + + def draw(self, context): + layout = self.layout + + if not context.selected_objects: + layout.label(text="No documents", icon="INFO") + return + + if len(context.selected_objects) > 1: + layout.label(text="Select a single object to see its referenced documents", icon="INFO") + return + + obj = context.active_object + if not obj or not tool.Blender.get_ifc_definition_id(obj): + layout.label(text="No documents", icon="INFO") + return + + if not ObjectDocumentData.is_loaded: + ObjectDocumentData.load() + + if not ObjectDocumentData.data["documents"]: + layout.label(text="No Documents", icon="FILE") + else: + for document in ObjectDocumentData.data["documents"]: + row = layout.row(align=True) + + with_ifc_icon = document["location"] and document["location"].lower().endswith(".ifc") + with_url_icon = bool(document["location"]) + + if with_ifc_icon: + row.operator("bim.open_ifc_document", icon="HIDE_OFF", text="").uri = document["location"] + else: + row.label(text="", icon="BLANK1") + + if with_url_icon: + row.operator("bim.open_uri", icon="URL", text="").uri = document["location"] + else: + row.label(text="", icon="BLANK1") + + doc_entity = None + if "id" in document: + doc_entity = tool.Ifc.get().by_id(document["id"]) + + if doc_entity and doc_entity.is_a("IfcDocumentReference"): + display_text = document.get("description") or "" + else: + display_text = document.get("name") or "" + + row.label(text=f"{document['identification'] or ''}: {display_text}") 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/helper.py b/src/bonsai/bonsai/bim/module/drawing/helper.py index 01a78aad11..4d99d6c91b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/helper.py +++ b/src/bonsai/bonsai/bim/module/drawing/helper.py @@ -313,15 +313,13 @@ def format_distance( if not feet and not add_inches: tx_dist += str(feet) + "'" - # Add "0' - " when we have inches but no feet - # But only add " - " separator if we actually have inches to show if not feet and add_inches: - tx_dist += "0' - " + if value < 0: + tx_dist += "-0' - " + else: + tx_dist += "0' - " elif feet and add_inches: tx_dist += " - " - - if not feet and value < 0: - tx_dist += "-" if add_inches: if feet == 0 and inches == 0 and not frac: # Special case: exactly zero, show "0" 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 2d8c4e60cf..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: @@ -257,14 +279,16 @@ class SvgWriter: self.height = self.raw_height * self.svg_scale def add_stylesheet(self): - path = self.resource_paths["Stylesheet"] - if not path: + paths = self.resource_paths["Stylesheet"] + if not paths: return - if not os.path.exists(path): - print(f"WARNING. Couldn't find stylesheet for the drawing by the path: {path}") - return - with open(path, "r") as stylesheet: - self.svg.defs.add(self.svg.style(stylesheet.read())) + 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}") + continue + with open(path, "r") as stylesheet: + self.svg.defs.add(self.svg.style(stylesheet.read())) def add_markers(self): path = self.resource_paths["Markers"] diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 552ca7f9b4..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] @@ -679,14 +678,14 @@ class BIM_PT_text(Panel): if len(literal_props.attributes) > 0 and i < len(props.literal_apply_settings): row = box.row(align=True) - row.prop(literal_props.attributes[0], "string_value", text="Literal") - + 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 d148b3df05..a35a4de7bc 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -1205,13 +1205,84 @@ class OverrideDuplicateMove(bpy.types.Operator): for obj in objects_to_remove: tool.Blender.deselect_object(obj) + # 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"): + parts = tool.Aggregate.get_parts_recursively(element) + for part in parts: + 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( - set(context.selected_objects) - objects_to_remove, + 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) + parent_obj = tool.Ifc.get_object(old_parent) + if new_obj and parent_obj: + bonsai.core.aggregate.assign_object( + tool.Ifc, + tool.Aggregate, + tool.Collector, + 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(): + for new_elem in new_elems: + 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) + for part in parts: + 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") + + # 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 @@ -1491,6 +1562,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): old_to_new = {} original_data: dict[int, dict[int, dict[str, Any]]] = {} + # Define all nested functions FIRST def delete_objects(element: ifcopenshell.entity_instance) -> None: """Remove IfcElementAssembly and it's parts.""" parts = ifcopenshell.util.element.get_parts(element) @@ -1542,7 +1614,10 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): if r.is_a("IfcRelAssignsToGroup") if self.group_name in r.RelatingGroup.Name ).id() - original_data[group] = {} + + # Initialize if not exists + if group not in original_data: + original_data[group] = {} pset: dict[str, Any] = ifcopenshell.util.element.get_pset(element, self.pset_name) index: int = pset["Index"] @@ -1559,8 +1634,13 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): if parts: for part in parts: if part.is_a("IfcElementAssembly"): - # TODO: unused expression. - original_data | get_original_data(part) + # Recursively collect data from nested assemblies + nested_data = get_original_data(part) + # Merge nested data into original_data + for nested_group_id, nested_group_data in nested_data.items(): + if nested_group_id not in original_data: + original_data[nested_group_id] = {} + original_data[nested_group_id].update(nested_group_data) else: try: pset = ifcopenshell.util.element.get_pset(part, self.pset_name) @@ -1584,50 +1664,123 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): ): # if element has parts it means it is the base of and aggregate or sub-aggregate aggregate = element - group = next( - r.RelatingGroup - for r in getattr(aggregate, "HasAssignments", []) or [] - if r.is_a("IfcRelAssignsToGroup") - if self.group_name in r.RelatingGroup.Name - ).id() - if not group: + # 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, + ) + + 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 + matching_group_id = None if index == 0: - obj.name = pset["Name"] + "_" + str(original_data[group][index]["Aggregate_Index"]) + # This is a root assembly - find by Name + aggregate_name = pset.get("Name") + for group_id, group_data in original_data.items(): + if 0 in group_data and group_data[0].get("Name") == aggregate_name: + matching_group_id = group_id + break + else: + # This is a part - find the group that has this index + for group_id, group_data in original_data.items(): + if index in group_data: + matching_group_id = group_id + break + + if matching_group_id is None: + return + + if index == 0: + obj.name = pset["Name"] + "_" + str(original_data[matching_group_id][index]["Aggregate_Index"]) ifc_file = tool.Ifc.get() ifcopenshell.api.pset.edit_pset( ifc_file, ifc_file.by_id(pset["id"]), - properties={"Aggregate_Index": int(original_data[group][index]["Aggregate_Index"])}, + properties={"Aggregate_Index": int(original_data[matching_group_id][index]["Aggregate_Index"])}, ) - bonsai.core.spatial.assign_container( - tool.Ifc, - tool.Collector, - tool.Spatial, - container=original_data[group][index]["Container"], - element_obj=obj, - ) - for part in ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(obj)): - tool.Collector.assign(tool.Ifc.get_object(part)) - assignments = original_data[group][index]["Assignment"] - if assignments: - assign_to_annotations(obj, assignments) + + # 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): + container = original_data[matching_group_id][index]["Container"] + bonsai.core.spatial.assign_container( + tool.Ifc, + tool.Collector, + tool.Spatial, + container=container, + element_obj=obj, + ) + + # Get the container's collection for moving parts in the outliner + container_obj = tool.Ifc.get_object(container) + container_collection = container_obj.BIMObjectProperties.collection if container_obj else None + + # Move all parts to the container's collection in the outliner + if container_collection: + for part in ifcopenshell.util.element.get_parts(element): + part_obj = tool.Ifc.get_object(part) + if part_obj: + # Remove from all previous collections + for col in part_obj.users_collection[:]: + col.objects.unlink(part_obj) + + # Link to container collection + if part_obj.name not in container_collection.objects: + container_collection.objects.link(part_obj) + + # Recursively handle nested parts + for nested_part in ifcopenshell.util.element.get_parts(part): + nested_part_obj = tool.Ifc.get_object(nested_part) + if nested_part_obj: + for col in nested_part_obj.users_collection[:]: + col.objects.unlink(nested_part_obj) + if nested_part_obj.name not in container_collection.objects: + container_collection.objects.link(nested_part_obj) else: try: - obj.name = original_data[group][index]["Name"] + obj.name = original_data[matching_group_id][index]["Name"] except: pass try: - assignments = original_data[group][index]["Assignment"] + assignments = original_data[matching_group_id][index]["Assignment"] except: assignments = [] if assignments: assign_to_annotations(obj, assignments) + def get_original_matrix( + element: ifcopenshell.entity_instance, base_instance: ifcopenshell.entity_instance + ) -> tuple[Matrix, tuple[Vector, Quaternion, Vector]]: + selected_obj = tool.Ifc.get_object(base_instance) + selected_matrix = selected_obj.matrix_world + object_duplicate = tool.Ifc.get_object(element) + duplicate_matrix = object_duplicate.matrix_world.decompose() + + return selected_matrix, duplicate_matrix + + def set_new_matrix( + selected_matrix: Matrix, duplicate_matrix: tuple[Vector, Quaternion, Vector], old_to_new: dict + ) -> None: + for old, new in old_to_new.items(): + new_obj = tool.Ifc.get_object(new[0]) + new_base_matrix = Matrix.LocRotScale(*duplicate_matrix) + matrix_diff = Matrix.inverted(selected_matrix) @ new_obj.matrix_world + new_obj_matrix = new_base_matrix @ matrix_diff + new_obj.matrix_world = new_obj_matrix + def get_element_assembly(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: if element.is_a("IfcElementAssembly"): return element @@ -1671,26 +1824,6 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): return list(set(linked_aggregate_groups)), selected_parents - def get_original_matrix( - element: ifcopenshell.entity_instance, base_instance: ifcopenshell.entity_instance - ) -> tuple[Matrix, tuple[Vector, Quaternion, Vector]]: - selected_obj = tool.Ifc.get_object(base_instance) - selected_matrix = selected_obj.matrix_world - object_duplicate = tool.Ifc.get_object(element) - duplicate_matrix = object_duplicate.matrix_world.decompose() - - return selected_matrix, duplicate_matrix - - def set_new_matrix( - selected_matrix: Matrix, duplicate_matrix: tuple[Vector, Quaternion, Vector], old_to_new: dict - ) -> None: - for old, new in old_to_new.items(): - new_obj = tool.Ifc.get_object(new[0]) - new_base_matrix = Matrix.LocRotScale(*duplicate_matrix) - matrix_diff = Matrix.inverted(selected_matrix) @ new_obj.matrix_world - new_obj_matrix = new_base_matrix @ matrix_diff - new_obj.matrix_world = new_obj_matrix - active_element = tool.Ifc.get_entity(context.active_object) if not active_element: self.report({"INFO"}, "Object has no Ifc metadata.") @@ -1727,6 +1860,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 @@ -1735,7 +1869,12 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): selected_matrix, duplicate_matrix = get_original_matrix(element, base_instance) - original_data = get_original_data(element) + # Merge data instead of overwriting + element_original_data = get_original_data(element) + for group_id, group_data in element_original_data.items(): + if group_id not in original_data: + original_data[group_id] = {} + original_data[group_id].update(group_data) delete_objects(element) @@ -1749,7 +1888,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): set_new_matrix(selected_matrix, duplicate_matrix, old_to_new) for old, new in old_to_new.items(): - if element_aggregate and new[0].is_a("IfcElementAssembly"): + if element_aggregate and new[0].is_a("IfcElementAssembly") and old == base_instance: new_aggregate = ifcopenshell.util.element.get_aggregate(new[0]) if not new_aggregate: diff --git a/src/bonsai/bonsai/bim/module/georeference/decorator.py b/src/bonsai/bonsai/bim/module/georeference/decorator.py index 55fcbc1d64..01818e9805 100644 --- a/src/bonsai/bonsai/bim/module/georeference/decorator.py +++ b/src/bonsai/bonsai/bim/module/georeference/decorator.py @@ -53,12 +53,13 @@ class GeoreferenceDecorator: pass cls.is_installed = False - def draw_batch(self, shader_type, content_pos, color, indices=None): + def draw_batch(self, shader_type, content_pos, color, indices=None, should_scale=True): if not tool.Blender.validate_shader_batch_data(content_pos, indices): return props = tool.Georeference.get_georeference_props() - self.scale = props.visualization_scale - content_pos = [v * self.scale for v in content_pos] + if should_scale: + scale = tool.Georeference.get_georeference_props().visualization_scale + content_pos = [v * scale for v in content_pos] shader = self.line_shader if shader_type == "LINES" else self.shader batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) shader.uniform_float("color", color) @@ -141,12 +142,13 @@ class GeoreferenceDecorator: if wcs["blender_location"].length < 1000: position = wcs["blender_location"].copy() + position -= Vector((0, 0.1, 0)) + self.draw_text_at_position(context, text, position, should_scale=False) else: position = wcs["blender_location"].normalized() * 3 text += "\n(Warning: Actual XYZ Not Shown)" - position -= Vector((0, 0.1, 0)) - - self.draw_text_at_position(context, text, position) + position -= Vector((0, 0.1, 0)) + self.draw_text_at_position(context, text, position) if props.has_blender_offset: text = "IFC Local Origin" @@ -165,8 +167,10 @@ class GeoreferenceDecorator: self.draw_text_at_position(context, text, location) blf.disable(self.font_id, blf.SHADOW) - def draw_text_at_position(self, context, text, position): - position = [v * self.scale for v in position] + def draw_text_at_position(self, context, text, position, should_scale=True): + if should_scale: + scale = tool.Georeference.get_georeference_props().visualization_scale + position = [v * scale for v in position] coords_2d = location_3d_to_region_2d(context.region, context.region_data, position) if not coords_2d: return @@ -309,8 +313,8 @@ class GeoreferenceDecorator: if wcs["blender_location"].length < 1000: verts = [Vector((0, 0, 0)), wcs["blender_location"]] edges = [[0, 1]] - self.draw_batch("LINES", verts, decorator_color_special, edges) - self.draw_batch("POINTS", verts[1:], decorator_color_special) + self.draw_batch("LINES", verts, decorator_color_special, edges, should_scale=False) + self.draw_batch("POINTS", verts[1:], decorator_color_special, should_scale=False) else: location = wcs["blender_location"].normalized() edges = [[0, 1]] @@ -332,7 +336,7 @@ class GeoreferenceDecorator: self.draw_batch("LINES", verts, decorator_color_special, edges) self.draw_dashed_line(location * 3, location * 6, decorator_color_error) - def draw_dashed_line(self, start, end, colour): + def draw_dashed_line(self, start, end, colour, should_scale=True): direction = (end - start).normalized() distance = (end - start).length current_distance = Vector((0, 0, 0)) @@ -347,7 +351,7 @@ class GeoreferenceDecorator: edges = [[i, i + 1] for i in range(0, len(points), 2)] verts = points - self.draw_batch("LINES", verts, colour, edges) + self.draw_batch("LINES", verts, colour, edges, should_scale=should_scale) def calculate_angles(self, context): self.pn_angle = 0.0 diff --git a/src/bonsai/bonsai/bim/module/georeference/prop.py b/src/bonsai/bonsai/bim/module/georeference/prop.py index 0a5eecf4c1..1b6088556b 100644 --- a/src/bonsai/bonsai/bim/module/georeference/prop.py +++ b/src/bonsai/bonsai/bim/module/georeference/prop.py @@ -215,7 +215,7 @@ class BIMGeoreferenceProperties(PropertyGroup): description="Affects the georeference decorator size", default=1, soft_min=0.1, - soft_max=50, + soft_max=100, ) grid_north_angle: StringProperty(name="Grid North Angle", update=update_grid_north_angle) x_axis_abscissa: StringProperty(name="X Axis Abscissa", update=update_grid_north_vector) diff --git a/src/bonsai/bonsai/bim/module/material/data.py b/src/bonsai/bonsai/bim/module/material/data.py index efe79c1731..b56ff93e33 100644 --- a/src/bonsai/bonsai/bim/module/material/data.py +++ b/src/bonsai/bonsai/bim/module/material/data.py @@ -176,6 +176,7 @@ class ObjectMaterialData: cls.data["active_material_constituents"] = cls.active_material_constituents() # after material_name and type_material cls.data["is_type_material_overridden"] = cls.is_type_material_overridden() + cls.data["bbim_material_layer_pset"] = cls.bbim_material_layer_pset() cls.is_loaded = True @@ -426,3 +427,35 @@ class ObjectMaterialData: # so we check occurrence material explicitly occurrence_material = ifcopenshell.util.element.get_material(cls.element, should_inherit=False) return bool(occurrence_material) + + @classmethod + def bbim_material_layer_pset(cls) -> Union[dict[str, Any], None]: + """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, + } diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index eef35d519c..f46f2c7b6a 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -509,6 +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( @@ -622,12 +626,16 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator): 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) + for layer_set in layer_sets_to_regenerate: wall.DumbWallPlaner().regenerate_from_layer_set(layer_set) slab.DumbSlabPlaner().regenerate_from_layer_set(layer_set) if material_set_usage.is_a("IfcMaterialProfileSetUsage"): - attributes["CardinalPoint"] = int(attributes["CardinalPoint"]) + if "CardinalPoint" in attributes: + attributes["CardinalPoint"] = int(attributes["CardinalPoint"]) ifcopenshell.api.material.edit_profile_usage( self.file, usage=material_set_usage, diff --git a/src/bonsai/bonsai/bim/module/material/ui.py b/src/bonsai/bonsai/bim/module/material/ui.py index 5c1b7d05b2..db05276d9b 100644 --- a/src/bonsai/bonsai/bim/module/material/ui.py +++ b/src/bonsai/bonsai/bim/module/material/ui.py @@ -20,6 +20,8 @@ from __future__ import annotations import bonsai.bim.helper import bonsai.tool as tool import bpy +import ifcopenshell.util.element +import ifcopenshell.util.unit from bpy.types import Panel, UIList from bonsai.bim.helper import draw_attributes from bonsai.bim.helper import prop_with_search @@ -134,8 +136,6 @@ class BIM_PT_object_material(Panel): @classmethod def poll(cls, context): - if not tool.Blender.is_tab(context, "GEOMETRY"): - return False if not (obj := context.active_object): return False ifc_id = tool.Blender.get_ifc_definition_id(obj) @@ -228,31 +228,47 @@ class BIM_PT_object_material(Panel): self.draw_read_only_set_ui() def draw_editable_set_ui(self): - bonsai.bim.helper.draw_attributes(self.props.material_set_attributes, self.layout) - bonsai.bim.helper.draw_attributes(self.props.material_set_usage_attributes, self.layout) + # 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) + + # Custom Offset Section self.draw_custom_offset() - if ObjectMaterialData.data["set_item_name"] == "profile" and not self.mprops.profiles: - row = self.layout.row(align=True) - row.label(text="No Profiles Available") - row.operator("bim.add_profile_def", icon="ADD", text="") - else: - layout = self.layout - layout.separator() - layout.separator() - row = self.layout.row(align=True) - if ObjectMaterialData.data["set_item_name"] == "profile": - prop_with_search(row, self.mprops, "profiles", icon="ITALIC", text="") - prop_with_search(row, self.props, "material", icon="MATERIAL", text="") - op = 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"]) + + # Dynamic header based on material set type + set_item_name = ObjectMaterialData.data["set_item_name"] + header_map = { + "layer": "Material Layers", + "profile": "Material Profiles", + "constituent": "Material Constituents", + "list_item": "Material List Items", + } + header_text = header_map.get(set_item_name, "Material Items") + self.layout.label(text=header_text) total_items = len(ObjectMaterialData.data["set_items"]) - layout = self.layout - box = layout.box() + 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) + box_row.label(text="No Profiles Available") + box_row.operator("bim.add_profile_def", icon="ADD", text="") + else: + box_row = box.row(align=True) + if ObjectMaterialData.data["set_item_name"] == "profile": + prop_with_search(box_row, self.mprops, "profiles", icon="ITALIC", text="") + 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_Exterior") + self.layerset_bounds(box, active_object, location="Top_Interior") if not ObjectMaterialData.data["set_items"]: row = box.row() @@ -269,7 +285,7 @@ class BIM_PT_object_material(Panel): else: self.draw_read_only_set_item_ui(box, set_item) - self.layerset_bounds(box, active_object, location="Bottom_Interior") + self.layerset_bounds(box, active_object, location="Bottom_Exterior") def draw_editable_set_item_profile_ui(self, box, set_item): # box = self.layout.box() @@ -335,29 +351,100 @@ class BIM_PT_object_material(Panel): setattr(op, f"{ObjectMaterialData.data['set_item_name']}_index", set_item["index"]) def draw_read_only_set_ui(self): + # Material Set Information Section + row = self.layout.row(align=True) + box = row.box() + if ObjectMaterialData.data["material_class"] != "IfcMaterialList": - row = self.layout.row(align=True) + box_row = box.row(align=True) set_name = ObjectMaterialData.data["set"]["name"] - row.label(text="Name") - row.label(text=set_name) + box_row.label(text="Name") + box_row.label(text=set_name) if value := ObjectMaterialData.data["set"]["description"]: - row = self.layout.row(align=True) - row.label(text="Description") - row.label(text=value) + box_row = box.row(align=True) + box_row.label(text="Description") + box_row.label(text=value) if ObjectMaterialData.data["material_class"] == "IfcMaterialProfileSetUsage": if value := ObjectMaterialData.data["set_usage"].get("cardinal_point"): - row = self.layout.row(align=True) - row.label(text="Cardinal Point") - row.label(text=value) + box_row = box.row(align=True) + box_row.label(text="Cardinal Point") + box_row.label(text=value) if ObjectMaterialData.data["total_thickness"]: - row = self.layout.row(align=True) - row.label(text="Total Thickness*") - row.label(text=ObjectMaterialData.data["total_thickness"]) + box_row = box.row(align=True) + box_row.label(text="Total Thickness*") + box_row.label(text=ObjectMaterialData.data["total_thickness"]) - box = self.layout.box() + # Display OffsetFromReferenceLine for layer sets + if "Layer" in ObjectMaterialData.data["material_class"]: + obj = bpy.context.active_object + if obj: + element = tool.Ifc.get_entity(obj) + if element: + material = ifcopenshell.util.element.get_material(element) + if material and material.is_a("IfcMaterialLayerSetUsage"): + offset_value = material.OffsetFromReferenceLine + # Format the offset value + unit_system = bpy.context.scene.unit_settings.system + prefs = tool.Blender.get_addon_preferences() + precision = None + 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 + ) + box_row = box.row(align=True) + box_row.label(text="Offset From Reference Line") + box_row.label(text=formatted_offset) + + # 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() + precision = None + 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 + ) + 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) + box_row.label(text=pset_data["reference_label"]) + box_row.label(text=pset_data["custom_reference"]) + + # Dynamic header based on material set type + set_item_name = ObjectMaterialData.data.get("set_item_name") + if set_item_name: + header_map = { + "layer": "Material Layers", + "profile": "Material Profiles", + "constituent": "Material Constituents", + "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() active_object = bpy.context.active_object self.layerset_bounds(box, active_object, location="Top_Interior") @@ -403,20 +490,27 @@ class BIM_PT_object_material(Panel): set_usage = ObjectMaterialData.data.get("set_usage", {}) layer_set_direction = set_usage.get("layer_set_direction") if layer_set_direction: - box = self.layout.box() - row = box.row(align=True) - row.prop(self.props, "use_custom_offset", text="Use Custom Offset") - row = box.row(align=True) - if layer_set_direction == "AXIS2": - row.prop(self.props, "custom_wall_reference", text="Reference") - row.enabled = self.props.use_custom_offset - if layer_set_direction == "AXIS3": - row.prop(self.props, "custom_slab_reference", text="Reference") - row.enabled = self.props.use_custom_offset + row = self.layout.row(align=True) + row.label(text="BBIM_MaterialLayer Pset") - row = box.row(align=True) - row.prop(self.props, "custom_offset", text="Custom Offset") - row.enabled = self.props.use_custom_offset + # 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") + box_row = box.row(align=True) + if layer_set_direction == "AXIS2": + box_row.prop(self.props, "custom_wall_reference", text="Reference") + box_row.enabled = self.props.use_custom_offset + if layer_set_direction == "AXIS3": + box_row.prop(self.props, "custom_slab_reference", text="Reference") + box_row.enabled = self.props.use_custom_offset + + box_row = box.row(align=True) + box_row.prop(self.props, "custom_offset", text="Custom Offset") + box_row.enabled = self.props.use_custom_offset class BIM_UL_materials(UIList): 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 ddc7c1d9a8..f5765331b6 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -35,7 +35,7 @@ import bonsai.core.geometry import bonsai.core.root import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from math import cos, pi +from math import cos, sin, pi, acos, degrees from mathutils import Vector, Matrix from bonsai.bim.module.model.decorator import ProfileDecorator, PolylineDecorator, ProductDecorator from bonsai.bim.module.model.polyline import PolylineOperator @@ -225,16 +225,17 @@ class DumbSlabPlaner: for inverse in tool.Ifc.get().get_inverse(layer_set): if not inverse.is_a("IfcMaterialLayerSetUsage") or inverse.LayerSetDirection != "AXIS3": continue + if tool.Ifc.get().schema == "IFC2X3": for rel in tool.Ifc.get().get_inverse(inverse): if not rel.is_a("IfcRelAssociatesMaterial"): continue for element in rel.RelatedObjects: - self.change_thickness(element, total_thickness) + self.change_thickness(element, total_thickness, preserve_offset=True) else: for rel in inverse.AssociatedTo: for element in rel.RelatedObjects: - self.change_thickness(element, total_thickness) + self.change_thickness(element, total_thickness, preserve_offset=True) def regenerate_from_type(self, usecase_path, ifc_file, settings): relating_type = settings["relating_type"] @@ -276,9 +277,10 @@ class DumbSlabPlaner: return self.change_thickness(element, total_thickness) - def change_thickness(self, element: ifcopenshell.entity_instance, thickness: float) -> None: + def change_thickness(self, element: ifcopenshell.entity_instance, thickness: float, preserve_offset: bool = False) -> None: if tool.Model.get_usage_type(element) != "LAYER3": return + layer_params = tool.Model.get_material_layer_parameters(element) ifc_file = tool.Ifc.get() body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") @@ -296,72 +298,48 @@ class DumbSlabPlaner: if representation: extrusion = tool.Model.get_extrusion(representation) if extrusion: - # TODO Right now we don't have a reliable way to calculate the existing x_angle only based solely on the extrusion direction. - # For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a - # -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation. - # This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach. - existing_x_angle = obj.rotation_euler.x - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) - offset_direction = direction_ratios.copy() - perpendicular_depth = thickness * abs(1 / cos(existing_x_angle)) - perpendicular_offset = layer_offset * abs(1 / cos(existing_x_angle)) / self.unit_scale + + # Calculate the actual extrusion angle from vertical + extrusion_angle = 0 + if direction_ratios.length > 0: + cos_angle = direction_ratios.normalized().dot(Vector((0, 0, 1))) + extrusion_angle = acos(min(max(cos_angle, -1), 1)) - # Check angle and z direction to determine whether the extrusion direction is positive or negative - if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or ( - abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0 - ): - # The extrusion direction is positive. If the layer_parameter is set to negative, - # then the we change the extrusion direction. - if layer_params["direction_sense"] == "NEGATIVE": - direction_ratios *= -1 - elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or ( - abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0 - ): - # The extrusion direction is negative. If the layer_parameter is set to positive, - # then the we change the extrusion direction. And the offset direction should remain positive - # for either direction sense, so we change it. - offset_direction *= -1 - if layer_params["direction_sense"] == "POSITIVE": - direction_ratios *= -1 + # Only apply 1/cos factor when there's actual extrusion slope + if extrusion_angle > 1e-6: + perpendicular_depth = thickness * abs(1 / cos(extrusion_angle)) + perpendicular_offset = layer_offset * abs(1 / cos(extrusion_angle)) + else: + perpendicular_depth = thickness + perpendicular_offset = layer_offset - extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios) extrusion.Depth = perpendicular_depth + # Update position ifc_position = extrusion.Position - position = offset_direction * perpendicular_offset - material = ifcopenshell.util.element.get_material(element) - if material: - if material.is_a("IfcMaterialLayerSetUsage"): - material.OffsetFromReferenceLine = position.z - if ifc_position: - ifc_position.Location.Coordinates = position - else: - tool.Model.add_extrusion_position(extrusion, position) - else: - props = tool.Model.get_model_props() - x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle - new_rep = ifcopenshell.api.geometry.add_slab_representation( - tool.Ifc.get(), - context=body_context, - depth=thickness * self.unit_scale, - x_angle=x_angle, - ) - for inverse in tool.Ifc.get().get_inverse(representation): - ifcopenshell.util.element.replace_attribute(inverse, representation, new_rep) - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=new_rep, - ) - bonsai.core.geometry.remove_representation( - tool.Ifc, tool.Geometry, obj=obj, representation=representation - ) - return + if direction_ratios.length > 0: + offset_vector = direction_ratios.normalized() * perpendicular_offset + position = offset_vector + + material = ifcopenshell.util.element.get_material(element) + if material and material.is_a("IfcMaterialLayerSetUsage"): + # Only set offset if not preserving it (preserves independent offsets per instance) + if not preserve_offset: + material.OffsetFromReferenceLine = position.z + + if ifc_position: + ifc_position.Location.Coordinates = position + else: + tool.Model.add_extrusion_position(extrusion, position) + + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + ) else: props = tool.Model.get_model_props() x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle @@ -374,13 +352,118 @@ class DumbSlabPlaner: ifcopenshell.api.geometry.assign_representation( tool.Ifc.get(), product=element, representation=representation ) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + ) - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=representation, - ) + def update_extrusion_direction( + element: ifcopenshell.entity_instance, new_direction_ratios: tuple, obj: bpy.types.Object = None + ) -> None: + """ + Update extrusion direction while preserving overall object orientation. + + Args: + element: The IFC element + new_direction_ratios: New extrusion direction ratios (x,y,z) + obj: Optional Blender object (will be fetched if not provided) + """ + if not obj: + obj = tool.Ifc.get_object(element) + if not obj: + return + + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if not representation: + return + + extrusion = tool.Model.get_extrusion(representation) + if not extrusion: + return + + # Get current extrusion direction + old_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios) + if old_direction.length == 0: + old_direction = Vector((0, 0, 1)) # Default + + new_direction = Vector(new_direction_ratios) + if new_direction.length == 0: + new_direction = Vector((0, 0, 1)) # Default + + # Normalize both directions + old_direction_normalized = old_direction.normalized() + new_direction_normalized = new_direction.normalized() + + # Store current object matrix + old_matrix = obj.matrix_world.copy() + + # Calculate the rotation needed to keep same orientation + # When extrusion direction changes from A to B relative to local coordinates, + # we need to rotate the object by the inverse of that change + + # Calculate rotation from old to new direction + rotation_axis = old_direction_normalized.cross(new_direction_normalized) + if rotation_axis.length > 1e-6: + rotation_axis.normalized() + dot_product = old_direction_normalized.dot(new_direction_normalized) + angle = acos(min(max(dot_product, -1), 1)) + + # Apply INVERSE rotation to object to compensate + rotation_matrix = Matrix.Rotation(-angle, 4, rotation_axis) + + # Update object rotation + obj.matrix_world = old_matrix @ rotation_matrix + bpy.context.view_layer.update() + + # Update extrusion direction (keeping magnitude) + if old_direction.length > 0: + # Preserve the magnitude of the original direction vector + magnitude = old_direction.length + new_direction = new_direction_normalized * magnitude + + extrusion.ExtrudedDirection.DirectionRatios = tuple(new_direction) + + # Update depth based on new extrusion angle + extrusion_angle = 0 + if new_direction.length > 0: + cos_angle = new_direction_normalized.dot(Vector((0, 0, 1))) + extrusion_angle = acos(min(max(cos_angle, -1), 1)) + + # Get current depth (perpendicular depth) + current_perpendicular_depth = extrusion.Depth + + # If we have material layer info, calculate actual thickness + material = ifcopenshell.util.element.get_material(element) + actual_thickness = current_perpendicular_depth + if material and material.is_a("IfcMaterialLayerSetUsage"): + layer_set = material.ForLayerSet + actual_thickness = sum([l.LayerThickness for l in layer_set.MaterialLayers]) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + actual_thickness *= unit_scale + + # Convert to perpendicular depth if needed + if extrusion_angle > 1e-6: + new_perpendicular_depth = actual_thickness * abs(1 / cos(extrusion_angle)) + else: + new_perpendicular_depth = actual_thickness + + extrusion.Depth = new_perpendicular_depth + + # Update position offset if needed + if extrusion.Position: + # Recalculate offset based on new direction + material = ifcopenshell.util.element.get_material(element) + if material and material.is_a("IfcMaterialLayerSetUsage"): + offset = material.OffsetFromReferenceLine + if extrusion_angle > 1e-6: + perpendicular_offset = offset * abs(1 / cos(extrusion_angle)) + else: + perpendicular_offset = offset + + offset_vector = new_direction_normalized * perpendicular_offset + extrusion.Position.Location.Coordinates = tuple(offset_vector) class EnableEditingSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): @@ -657,6 +740,8 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): existing_x_angle = tool.Model.get_existing_x_angle(extrusion) layer_params = tool.Model.get_material_layer_parameters(element) + usage_type = tool.Model.get_usage_type(element) + if extrusion.Position: position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist()) position.translation *= self.unit_scale @@ -669,22 +754,49 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): tranlation_matrix = Matrix.Translation(rot_offset) position = position @ tranlation_matrix - # Restore Object rotation to zero - local_rot_mat = obj.rotation_euler.to_matrix() - rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X") - new_rot_mat = local_rot_mat.to_4x4() @ rot_mat - new_rot_euler = new_rot_mat.to_euler() - obj.rotation_euler = new_rot_euler + # For AXIS3 with dual rotation: Reset rotation to zero so profile is horizontal + if usage_type == "LAYER3": + # Store original rotation for later restoration + original_rotation_x = obj.rotation_euler.x + obj["pre_edit_rotation_x"] = original_rotation_x + + # Reset rotation to zero - profile will be horizontal + current_z_rot = obj.rotation_euler.z + obj.rotation_euler.x = 0.0 + obj.rotation_euler.z = current_z_rot + else: + # Original behavior: Restore Object rotation to zero + local_rot_mat = obj.rotation_euler.to_matrix() + rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X") + new_rot_mat = local_rot_mat.to_4x4() @ rot_mat + new_rot_euler = new_rot_mat.to_euler() + obj.rotation_euler = new_rot_euler else: position = Matrix() - tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle) + # Import profile with correct x_angle + if usage_type == "LAYER3": + # For LAYER3: Use x_angle=0 and scale by cos(rotation) to get horizontal projection + obj_x_rotation = original_rotation_x # Use stored original rotation + scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0 + + # Import with x_angle=0 + tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=0) + + # Scale the Y coordinates by cos(rotation) to get horizontal projection + bpy.ops.object.mode_set(mode="OBJECT") + for vert in obj.data.vertices: + vert.co.y *= scale_factor + else: + # For other types: Use existing_x_angle + tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle) bpy.ops.object.mode_set(mode="EDIT") ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_extrusion_profile(context)) if not bpy.app.background: tool.Blender.set_viewport_tool("bim.cad_tool") + return {"FINISHED"} @@ -706,6 +818,8 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): extrusion = tool.Model.get_extrusion(body) existing_x_angle = tool.Model.get_existing_x_angle(extrusion) layer_params = tool.Model.get_material_layer_parameters(element) + usage_type = tool.Model.get_usage_type(element) + if extrusion.Position: position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist()) position.translation *= self.unit_scale @@ -718,17 +832,38 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): tranlation_matrix = Matrix.Translation(rot_offset) position = position @ tranlation_matrix - # Restore Object rotation to x_angle - local_rot_mat = obj.rotation_euler.to_matrix() - rot_mat = Matrix.Rotation(existing_x_angle, 4, "X") - new_rot_mat = local_rot_mat.to_4x4() @ rot_mat - new_rot_euler = new_rot_mat.to_euler() - obj.rotation_euler = new_rot_euler + # Restore rotation + if usage_type == "LAYER3": + # Restore original rotation from before editing + if "pre_edit_rotation_x" in obj: + current_z_rot = obj.rotation_euler.z + obj.rotation_euler.x = obj["pre_edit_rotation_x"] + obj.rotation_euler.z = current_z_rot + del obj["pre_edit_rotation_x"] + else: + # Original behavior + local_rot_mat = obj.rotation_euler.to_matrix() + rot_mat = Matrix.Rotation(existing_x_angle, 4, "X") + new_rot_mat = local_rot_mat.to_4x4() @ rot_mat + new_rot_euler = new_rot_mat.to_euler() + obj.rotation_euler = new_rot_euler else: position = Matrix() - profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle) + # Export profile with correct x_angle + if usage_type == "LAYER3": + # Scale Y coordinates back up before exporting + obj_x_rotation = obj.rotation_euler.x + scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0 + + # Un-scale the profile before exporting + for vert in obj.data.vertices: + vert.co.y /= scale_factor # Inverse of import scaling + + profile = tool.Model.export_profile(obj, position=position, x_angle=0) + else: + profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle) if not profile: @@ -780,6 +915,28 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): tool.Ifc.get(), product=element, representation=new_footprint ) + footprint_context = ifcopenshell.util.representation.get_context( + tool.Ifc.get(), "Plan", "FootPrint", "SKETCH_VIEW" + ) + if not footprint_context: + return + + curves = [profile.OuterCurve] + if profile.is_a("IfcArbitraryProfileDefWithVoids"): + curves.extend(profile.InnerCurves) + new_footprint = ifcopenshell.api.geometry.add_footprint_representation( + tool.Ifc.get(), context=footprint_context, curves=curves + ) + old_footprint = ifcopenshell.util.representation.get_representation(element, "Plan", "FootPrint", "SKETCH_VIEW") + if old_footprint: + for inverse in tool.Ifc.get().get_inverse(old_footprint): + ifcopenshell.util.element.replace_attribute(inverse, old_footprint, new_footprint) + bonsai.core.geometry.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_footprint) + else: + ifcopenshell.api.geometry.assign_representation( + tool.Ifc.get(), product=element, representation=new_footprint + ) + class ResetVertex(bpy.types.Operator): bl_idname = "bim.reset_vertex" diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 5b858dec83..d652041125 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -43,7 +43,7 @@ import bonsai.core.geometry import bonsai.core.model as core import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from math import pi, sin, cos, degrees, atan2 +from math import pi, sin, cos, degrees, atan2, acos from mathutils import Vector, Matrix from bonsai.bim.module.model.opening import FilledOpeningGenerator from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator @@ -397,27 +397,46 @@ 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))) - extrusion.Depth = self.depth / si_conversion * (1 / cos(x_angle)) + + # For sloped walls, compensate so VERTICAL height = target depth + cos_angle = cos(x_angle) + compensation_factor = abs(1 / cos_angle) if abs(cos_angle) > 1e-6 else 1.0 + new_depth_ifc = (self.depth / si_conversion) * compensation_factor + + extrusion.Depth = new_depth_ifc + + # IMPORTANT: Refresh the geometry to reflect the IFC changes + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + ) + if tool.Model.get_usage_type(element) == "LAYER2": for rel in element.ConnectedFrom: if rel.is_a() == "IfcRelConnectsElements": - ifcopenshell.api.geometry.disconnect_element( - ifc_file, - relating_element=rel.RelatingElement, - related_element=element, - ) - layer2_objs.append(obj) + related_element = rel.RelatedElement + if related_element.is_a() == "IfcWall": + layer2_objs.append(tool.Ifc.get_object(related_element)) if layer2_objs: tool.Model.recalculate_walls(layer2_objs) + return {"FINISHED"} @@ -437,81 +456,143 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): layer2_objs: list[bpy.types.Object] = [] - x_angle = 0 if tool.Cad.is_x(self.x_angle, 0, tolerance=0.001) else self.x_angle - x_angle = 0 if tool.Cad.is_x(self.x_angle, pi, tolerance=0.001) else self.x_angle - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - selected_objs = tool.Model.get_selected_mesh_ifc_objects() builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + x_angle = self.x_angle - for obj in selected_objs: + for obj in context.selected_objects: element = tool.Ifc.get_entity(obj) - assert element + if not element: + continue + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: continue extrusion = tool.Model.get_extrusion(representation) if not extrusion: continue + + # Get current object rotation matrix + obj_rotation = obj.matrix_world.to_3x3() + + # Get current extrusion direction in LOCAL coordinates + current_local_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios) + if current_local_direction.length == 0: + current_local_direction = Vector((0, 0, 1)) + current_local_direction_normalized = current_local_direction.normalized() + + # Calculate what the current extrusion direction is in WORLD coordinates + current_world_direction = obj_rotation @ current_local_direction_normalized + existing_x_angle = tool.Model.get_existing_x_angle(extrusion) existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle + + # Calculate the NEW local extrusion direction based on x_angle + new_local_direction = Vector((0.0, sin(x_angle), cos(x_angle))) + + # Check if extrusion direction is actually changing + current_local_norm = current_local_direction_normalized + new_local_norm = new_local_direction.normalized() + + # Compare the LOCAL directions + local_direction_changed = (new_local_norm - current_local_norm).length > 1e-6 + if tool.Model.get_usage_type(element) == "LAYER2": - x, y, z = extrusion.ExtrudedDirection.DirectionRatios depth = extrusion.Depth / abs(1 / cos(existing_x_angle)) perpendicular_depth = depth * abs(1 / cos(x_angle)) - extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle)) - layer2_objs.append(obj) + + # 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": - existing_x_angle = obj.rotation_euler.x - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle + # 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 + ) + # Scale the polyline coordinates coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve) coord_list = [ (p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list - ] # Reset the transformation and returns to the original points with 0 degrees + ] # Reset the transformation coord_list = [ (p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list ] # Apply the transformation for the new x_angle builder.set_polyline_coords(extrusion.SweptArea.OuterCurve, coord_list) - # The extrusion direction calculated previously default to the positive direction - # Here we set the extrusion direction to negative if that's the case - direction_ratios = Vector((0.0, sin(x_angle), cos(x_angle))) - # direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) + # Calculate new extrusion direction with direction sense + base_local_direction = Vector((0.0, sin(x_angle), cos(x_angle))) layer_params = tool.Model.get_material_layer_parameters(element) perpendicular_depth = layer_params["thickness"] * abs(1 / cos(x_angle)) / unit_scale perpendicular_offset = layer_params["offset"] * abs(1 / cos(x_angle)) / unit_scale - offset_direction = direction_ratios.copy() + offset_direction = base_local_direction.copy() - # Check angle and z direction to determine whether the extrusion direction is positive or negative - if (abs(x_angle) < (pi / 2) and direction_ratios.z > 0) or ( - abs(x_angle) > (pi / 2) and direction_ratios.z < 0 + # Apply direction sense + final_local_direction = base_local_direction.copy() + if (abs(x_angle) < (pi / 2) and base_local_direction.z > 0) or ( + abs(x_angle) > (pi / 2) and base_local_direction.z < 0 ): - # The extrusion direction is positive. If the layer_parameter is set to negative, - # then the we change the extrusion direction. if layer_params["direction_sense"] == "NEGATIVE": - direction_ratios *= -1 - elif ((x_angle) > (pi / 2) and direction_ratios.z > 0) or ( - (x_angle) < (pi / 2) and direction_ratios.z < 0 + final_local_direction *= -1 + elif (x_angle > (pi / 2) and base_local_direction.z > 0) or ( + x_angle < (pi / 2) and base_local_direction.z < 0 ): - # The extrusion direction is negative. If the layer_parameter is set to positive, - # then the we change the extrusion direction. - # then the we change the extrusion direction. And the offset direction should remain positive - # for either direction sense, so we change it. offset_direction *= -1 if layer_params["direction_sense"] == "POSITIVE": - direction_ratios *= -1 + final_local_direction *= -1 - extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios) + # 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 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)) + + # Rotate around object's own origin + # Decompose the matrix to get translation, rotation, scale + translation, rotation, scale = obj.matrix_world.decompose() + + # Create rotation matrix and convert to quaternion + rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis) + rotation_quat = rotation_matrix.to_quaternion() + + # Apply rotation to existing rotation (quaternion multiplication) + new_rotation = rotation_quat @ rotation + + # Reconstruct matrix_world with same translation, new rotation, same scale + obj.matrix_world = ( + Matrix.Translation(translation) @ new_rotation.to_matrix().to_4x4() @ Matrix.Scale(1, 4) + ) + bpy.context.view_layer.update() + bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, @@ -519,12 +600,6 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): representation=representation, ) - # Object rotation - current_z_rot = obj.rotation_euler.z - rot_mat = mathutils.Matrix.Rotation(x_angle, 4, "X") - obj.rotation_euler = rot_mat.to_euler() - obj.rotation_euler.z = current_z_rot - if layer2_objs: tool.Model.recalculate_walls(layer2_objs) return {"FINISHED"} @@ -1022,6 +1097,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 30acee46e3..578fbefa2a 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -399,6 +399,16 @@ 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() + if props.representation_obj: + parent_element = tool.Ifc.get_entity(props.representation_obj) + 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. @@ -412,8 +422,12 @@ class EditItemUI: op.profile_id = int(mesh_props.item_profile) for item_attribute in mesh_props.item_attributes: + # Skip depth attribute for LAYER3 objects with layer set usage + if has_layer_set_usage and item_attribute.name.lower() == "depth": + 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 c0dd8c70c1..b0f74cfb40 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1062,18 +1062,34 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): and not self.is_advanced ): filepath = self.get_filepath() - suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix - if str(filepath).lower().endswith(".ifc"): - metadata_path = Path(str(filepath)[:-4] + suffix) - else: - metadata_path = Path(str(filepath) + suffix) - if metadata_path.exists() and metadata_path.is_file(): - try: - bpy.ops.bim.load_blend_metadata_and_ifc(filepath=filepath) - self.report({"INFO"}, f"Loaded metadata file: {metadata_path.name}") - return {"FINISHED"} - except Exception as e: - self.report({"WARNING"}, f"Failed to load metadata file, using regular load: {e}") + + # First, load the IFC file temporarily to check for metadata document + temp_ifc = None + has_metadata_doc = False + try: + temp_ifc = ifcopenshell.open(str(filepath)) + for doc in temp_ifc.by_type("IfcDocumentInformation"): + if getattr(doc, "Scope", None) == "BLEND_METADATA": + has_metadata_doc = True + break + except: + 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"): + metadata_path = Path(str(filepath)[:-4] + suffix) + else: + metadata_path = Path(str(filepath) + suffix) + if metadata_path.exists() and metadata_path.is_file(): + try: + bpy.ops.bim.load_blend_metadata_and_ifc(filepath=filepath) + self.report({"INFO"}, f"Loaded metadata file: {metadata_path.name}") + return {"FINISHED"} + except Exception as e: + self.report({"WARNING"}, f"Failed to load metadata file, using regular load: {e}") @persistent def load_handler(*args): @@ -1121,6 +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()) @@ -1749,6 +1769,22 @@ class ExportIFC(bpy.types.Operator, ExportHelper): settings.json_version = self.json_version settings.json_compact = self.json_compact + pprops = tool.Project.get_project_props() + if tool.Blender.get_addon_preferences().save_metadata_blend_file and pprops.should_save_metadata_for_this_file: + suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix + if output_file.lower().endswith(".ifc"): + 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: + tool.Project.update_metadata_document_information(metadata_filename) + else: + if not pprops.should_save_metadata_for_this_file: + tool.Project.remove_metadata_document_information() + ifc_exporter = export_ifc.IfcExporter(settings) print("Starting export") settings.logger.info("Starting export") @@ -1765,7 +1801,8 @@ class ExportIFC(bpy.types.Operator, ExportHelper): tool.Ifc.set_path(output_file) bim_props.is_dirty = False - if tool.Blender.get_addon_preferences().save_metadata_blend_file: + pprops = tool.Project.get_project_props() + if tool.Blender.get_addon_preferences().save_metadata_blend_file and pprops.should_save_metadata_for_this_file: try: bpy.ops.bim.save_blend_metadata_file() suffix = tool.Blender.get_addon_preferences().metadata_blend_file_suffix @@ -3128,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" @@ -3165,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/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 2cbdeb6e7b..8983c927c9 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -411,6 +411,11 @@ class BIMProjectProperties(PropertyGroup): ) use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=False) + should_save_metadata_for_this_file: BoolProperty( + name="Save Session Data for This File", + description="Enable saving session data (window layout, settings) to a metadata blend file for this specific IFC file", + default=False, + ) queried_obj: bpy.props.PointerProperty(type=bpy.types.Object) queried_obj_root: bpy.props.PointerProperty(type=bpy.types.Object) clipping_planes: bpy.props.CollectionProperty(type=ObjProperty) @@ -504,6 +509,7 @@ class BIMProjectProperties(PropertyGroup): parent_library: str use_relative_project_path: bool + should_save_metadata_for_this_file: bool queried_obj: Union[bpy.types.Object, None] queried_obj_root: Union[bpy.types.Object, None] clipping_planes: bpy.types.bpy_prop_collection_idprop[ObjProperty] diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 5d4f3f695b..1a6f727b20 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -338,9 +338,9 @@ class BIM_PT_project(Panel): else: metadata_filename = os.path.basename(props.ifc_file) + suffix row = self.layout.row(align=True) - col = row.column() - col.enabled = False - col.label(text=f"Saving session data to: {metadata_filename}") + row.use_property_split = False + pprops = tool.Project.get_project_props() + row.prop(pprops, "should_save_metadata_for_this_file", text=f"Save session data to: {metadata_filename}") class BIM_PT_new_project_wizard(Panel): diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index e79cfc7d47..075c3a9a38 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -175,31 +175,84 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): else: return + def get_root_aggregate(element): + """Traverse up the aggregate hierarchy to find the top-most aggregate""" + current = element + root = None + while aggregate := ifcopenshell.util.element.get_aggregate(current): + root = aggregate + current = aggregate + return root + + def get_all_parts_recursive(element): + """Recursively get all parts of an aggregate""" + parts = [] + for part in ifcopenshell.util.element.get_parts(element): + parts.append(part) + # Recursively get nested parts + parts.extend(get_all_parts_recursive(part)) + return parts + objs: list[bpy.types.Object] = [] - # In IFC element can be either contained of aggregated, - # tehrefore we skip aggregated elements here to prevent confusion. - # Can't handle it in `poll` since user might just select bunch of elements - # and try to assign a container to them - # and excluding aggregates because of the `poll` failing might get awkward. - skipped_aggregates = 0 + processed_elements = set() # Track elements we've already handled (by IFC ID) + promoted_parts = 0 # Count how many parts were promoted to their root aggregate + for obj in tool.Blender.get_selected_objects(): if not (element := tool.Ifc.get_entity(obj)): continue - if ifcopenshell.util.element.get_aggregate(element): - skipped_aggregates += 1 - continue - objs.append(obj) + + # Check if element is part of an aggregate (at any level) + if root_aggregate := get_root_aggregate(element): + # Skip if we've already processed this root aggregate + if root_aggregate.id() in processed_elements: + continue + + # Get the root aggregate object and add it instead + if root_aggregate_obj := tool.Ifc.get_object(root_aggregate): + objs.append(root_aggregate_obj) + processed_elements.add(root_aggregate.id()) + if root_aggregate != element: # Only count as promoted if different from selected + promoted_parts += 1 + else: + # Element is not part of any aggregate + if element.id() not in processed_elements: + objs.append(obj) + processed_elements.add(element.id()) + + # Get the container's collection + container_obj = tool.Ifc.get_object(container) + container_collection = container_obj.BIMObjectProperties.collection if container_obj else None for element_obj in objs: + element = tool.Ifc.get_entity(element_obj) + + # Only assign container to the ROOT aggregate (this updates IFC relationships) if self.remove_from_other_containers: for col in element_obj.users_collection[:]: col.objects.unlink(element_obj) core.assign_container(tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj) - aggregates_msg = "" - if skipped_aggregates: - aggregates_msg = f" {skipped_aggregates} aggregated elements skipped." - self.report({"INFO"}, f"{len(objs)} elements assigned.{aggregates_msg}") + # For parts, only move them in Blender collections (don't change IFC relationships) + if container_collection: + all_parts = get_all_parts_recursive(element) + for part in all_parts: + if part_obj := tool.Ifc.get_object(part): + # Always remove from ALL previous collections when moving to new container + for col in part_obj.users_collection[:]: + col.objects.unlink(part_obj) + + # Link to new container collection (Blender-only, no IFC change) + if part_obj.name not in container_collection.objects: + container_collection.objects.link(part_obj) + + # Disable editing mode for all selected objects + for obj in tool.Blender.get_selected_objects(): + core.disable_editing_container(tool.Spatial, obj=obj) + + promoted_msg = "" + if promoted_parts: + promoted_msg = f" {promoted_parts} nested parts promoted to their root aggregates." + self.report({"INFO"}, f"{len(objs)} elements assigned.{promoted_msg}") class EnableEditingContainer(bpy.types.Operator): diff --git a/src/bonsai/bonsai/bim/module/spatial/prop.py b/src/bonsai/bonsai/bim/module/spatial/prop.py index b7ae727a26..3d54581551 100644 --- a/src/bonsai/bonsai/bim/module/spatial/prop.py +++ b/src/bonsai/bonsai/bim/module/spatial/prop.py @@ -98,6 +98,8 @@ def update_name(self: "BIMContainer", context: bpy.types.Context) -> None: tool.Spatial.edit_container_name(element, self.name) if obj := tool.Ifc.get_object(element): tool.Root.set_object_name(obj, element) + if collection := tool.Blender.get_object_bim_props(obj).collection: + collection.name = f"{element.is_a()}/{element.Name or 'Unnamed'}" bonsai.bim.handler.refresh_ui_data() diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index c078d66f67..ce70f59231 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -239,7 +239,7 @@ class SelectSimilarType(bpy.types.Operator): for related_object in objects: relating_type = ifcopenshell.util.element.get_type(tool.Ifc.get_entity(related_object)) if not relating_type: - related_object.select_set(False) + # Keep objects without a type selected (retain current selection) continue relating_types.add(relating_type) diff --git a/src/bonsai/bonsai/bim/operator.py b/src/bonsai/bonsai/bim/operator.py index d8c1cab7d1..3621344542 100644 --- a/src/bonsai/bonsai/bim/operator.py +++ b/src/bonsai/bonsai/bim/operator.py @@ -33,15 +33,6 @@ import bonsai.bim import bonsai.tool as tool import bonsai.bim.handler from enum import Enum -from bonsai.bim.helper import ( - get_all_tab_panels, - get_tab_visibility, - set_tab_visibility, - get_tab_names, - get_panel_config, - initialize_panel_properties, - initialize_tab_visibilities, -) from bpy_extras.io_utils import ImportHelper from bonsai.bim import import_ifc from bonsai.bim.prop import StrProperty @@ -364,8 +355,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? @@ -1737,156 +1726,6 @@ class BIM_OT_attribute_remove_subitem(bpy.types.Operator): return {"FINISHED"} -class BIM_UL_tab_panels(bpy.types.UIList): - """UIList for Tab Panels""" - - def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): - row = layout.row(align=True) - row.label(text=item["bl_label"]) - - row.operator( - "bim.toggle_panel_visibility", - text="", - icon="HIDE_OFF" if item.get("visible", True) else "HIDE_ON", - ).action = f"TOGGLE_VISIBILITY_{item.name}" - - row.operator( - "bim.bookmark_panel", - text="", - icon="SOLO_ON" if item.get("bookmarked", False) else "SOLO_OFF", - ).action = f"BOOKMARK_{item.name}" - - -class BIM_OT_toggle_panel_visibility(bpy.types.Operator): - """Toggle Panel Visibility""" - - bl_idname = "bim.toggle_panel_visibility" - bl_label = "Toggle Panel Visibility" - bl_options = {"REGISTER", "UNDO"} - - action: bpy.props.StringProperty() - - def execute(self, context): - panel_name = self.action.replace("TOGGLE_VISIBILITY_", "") - active_tab = getattr(context.scene, "active_tab_name", None) or getattr( - tool.Blender.get_bim_props(), "tab", None - ) - is_bookmark_tab = active_tab == "BOOKMARK" - - panel_config = get_panel_config(panel_name, create_if_missing=True) - if panel_config: - if is_bookmark_tab: - panel_config.is_visible_in_bookmarks = not panel_config.is_visible_in_bookmarks - new_value = panel_config.is_visible_in_bookmarks - else: - panel_config.is_visible_in_tab = not panel_config.is_visible_in_tab - new_value = panel_config.is_visible_in_tab - - for item in context.scene.tab_panels: - if item.name == panel_name: - item["visible"] = new_value - break - - for area in bpy.context.window.screen.areas: - if area.type == "PROPERTIES": - area.tag_redraw() - - tab_context = "Bookmarks" if is_bookmark_tab else "Tab" - self.report({"INFO"}, f"Toggled visibility for {panel_name} in {tab_context}.") - return {"FINISHED"} - - -class BIM_OT_bookmark_panel(bpy.types.Operator): - """Bookmark Panel""" - - bl_idname = "bim.bookmark_panel" - bl_label = "Bookmark Panel" - bl_options = {"REGISTER", "UNDO"} - - action: bpy.props.StringProperty() - - def execute(self, context): - panel_name = self.action.replace("BOOKMARK_", "") - panel_config = get_panel_config(panel_name, create_if_missing=True) - - if panel_config: - panel_config.is_bookmarked = not panel_config.is_bookmarked - - for item in context.scene.tab_panels: - if item.name == panel_name: - item["bookmarked"] = panel_config.is_bookmarked - break - - for area in bpy.context.window.screen.areas: - if area.type == "PROPERTIES": - area.tag_redraw() - - self.report({"INFO"}, f"Toggled bookmark for {panel_name}.") - return {"FINISHED"} - - -class BIM_OT_manage_tab_panels(bpy.types.Operator): - """Manage Tab Panels""" - - bl_idname = "bim.manage_tab_panels" - bl_label = "Manage Tab Panels" - bl_options = {"REGISTER", "UNDO"} - - tab_name: bpy.props.StringProperty() - - def invoke(self, context, event): - - context.scene.active_tab_name = self.tab_name - context.scene.tab_panels.clear() - - initialize_tab_visibilities() - initialize_panel_properties() - all_panels = get_all_tab_panels(force_refresh=True) - - for panel_data in all_panels.get(self.tab_name, []): - panel_name = panel_data.get("bl_idname", "") - panel_label = panel_data.get("bl_label", "") - if not panel_name or not panel_label: - continue - - item = context.scene.tab_panels.add() - item.name = panel_name - item["bl_label"] = panel_label - - panel_config = get_panel_config(panel_name, create_if_missing=True) - if panel_config: - if self.tab_name == "BOOKMARK": - item["visible"] = panel_config.is_visible_in_bookmarks - else: - item["visible"] = panel_config.is_visible_in_tab - item["bookmarked"] = panel_config.is_bookmarked - else: - item["visible"] = True - item["bookmarked"] = False - - return context.window_manager.invoke_popup(self) - - def draw(self, context): - layout = self.layout - layout.label(text=f"Manage Panels for {self.tab_name} Tab") - - row = layout.row() - row.template_list("BIM_UL_tab_panels", "", context.scene, "tab_panels", context.scene, "active_tab_panel_index") - - def execute(self, context): - for item in context.scene.tab_panels: - panel_config = get_panel_config(item.name, create_if_missing=True) - if panel_config: - if self.tab_name == "BOOKMARK": - panel_config.is_visible_in_bookmarks = item["visible"] - else: - panel_config.is_visible_in_tab = item["visible"] - panel_config.is_bookmarked = item["bookmarked"] - - self.report({"INFO"}, f"Panels for {self.tab_name} managed successfully.") - return {"FINISHED"} - - class BIM_OT_manage_tab_visibility(bpy.types.Operator): """Manage Tab Visibility""" @@ -1894,51 +1733,26 @@ class BIM_OT_manage_tab_visibility(bpy.types.Operator): bl_label = "Manage Tab Visibility" bl_options = {"REGISTER", "UNDO"} - def draw(self, context): - layout = self.layout - row = layout.row() - row = self.layout.row(align=True) - row.alignment = "RIGHT" - - row.operator("bim.reset_ui_layout", icon="FILE_REFRESH", text="") - row = layout.row() - row = self.layout.row(align=True) - row.alignment = "CENTER" - - for tab_name in get_tab_names(): - row = layout.row() - row.label(text=tab_name) - is_visible = get_tab_visibility(tab_name) - icon = "HIDE_OFF" if is_visible else "HIDE_ON" - op = row.operator("bim.toggle_tab_visibility", text="", icon=icon) - op.tab_name = tab_name - def execute(self, context): - return {"FINISHED"} + from bonsai.bim.prop import get_tab - def invoke(self, context, event): - return context.window_manager.invoke_popup(self) + bprops = tool.Blender.get_bim_props() + bprops.tab_visibilities.clear() + bprops.panel_visibilities.clear() + tabs = [item[0] for item in get_tab(None, None) if item and item[0] != "BLENDER"] + for tab in tabs: + new = bprops.tab_visibilities.add() + new.name = tab - -class BIM_OT_toggle_tab_visibility(bpy.types.Operator): - """Toggle Tab Visibility""" - - bl_idname = "bim.toggle_tab_visibility" - bl_label = "Toggle Tab Visibility" - bl_options = {"REGISTER", "UNDO"} - - tab_name: bpy.props.StringProperty() - - def execute(self, context): - if self.tab_name in get_tab_names(): - current_visibility = get_tab_visibility(self.tab_name) - set_tab_visibility(self.tab_name, not current_visibility) - - for area in bpy.context.window.screen.areas: - if area.type == "PROPERTIES": - area.tag_redraw() - - self.report({"INFO"}, f"Toggled visibility for {self.tab_name}.") + for attr_name in dir(bpy.types): + if attr_name.startswith("BIM_PT_tab_"): + panel_class = getattr(bpy.types, attr_name) + if not hasattr(panel_class, "bl_idname"): + assert False, panel_class + new = bprops.panel_visibilities.add() + new.name = panel_class.bl_idname + new.label = panel_class.bl_label + new.tab_name = panel_class.bim_tab_name return {"FINISHED"} @@ -1950,29 +1764,8 @@ class BIM_OT_reset_ui_layout(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - - for tab_name in get_tab_names(): - set_tab_visibility(tab_name, True) - - get_all_tab_panels()["BOOKMARK"] = [{}] - - for tab_name, panels in get_all_tab_panels().items(): - for panel in panels: - panel_name = panel.get("bl_idname", "") - if not panel_name: - continue - - show_prop_name = f"show_{panel_name.lower()}" - if hasattr(context.scene, show_prop_name): - setattr(context.scene, show_prop_name, True) - - bookmark_prop_name = f"bookmark_{panel_name.lower()}" - if hasattr(context.scene, bookmark_prop_name): - setattr(context.scene, bookmark_prop_name, False) - - for area in bpy.context.window.screen.areas: - if area.type == "PROPERTIES": - area.tag_redraw() - - self.report({"INFO"}, "UI layout reset to default.") + bprops = tool.Blender.get_bim_props() + bprops.tab_visibilities.clear() + bprops.panel_visibilities.clear() + bonsai.bim.handler.refresh_ui_data() return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index d4ed4f8a9b..ec8154b167 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -60,6 +60,10 @@ def update_tab(self: "BIMAreaProperties", context: bpy.types.Context) -> None: self.previous_tab = self.tab +def update_is_visible(self: "BIMTabVisibility", context: bpy.types.Context) -> None: + bonsai.bim.handler.refresh_ui_data() + + def update_global_tab(self: "BIMTabProperties", context: bpy.types.Context) -> None: tool.Blender.setup_tabs() screen = context.id_data @@ -537,21 +541,24 @@ class BIMTabProperties(PropertyGroup): class BIMTabVisibility(PropertyGroup): name: StringProperty(name="Tab Name") - is_visible: BoolProperty(name="Is Visible", default=True) + is_visible: BoolProperty(name="Is Visible", default=True, update=update_is_visible) if TYPE_CHECKING: name: str is_visible: bool -class BIMPanelProperties(PropertyGroup): - is_visible_in_tab: BoolProperty(name="Is Visible in Tab", default=True) - is_visible_in_bookmarks: BoolProperty(name="Is Visible in Bookmarks", default=True) - is_bookmarked: BoolProperty(name="Is Bookmarked", default=False) +class BIMPanelVisibility(PropertyGroup): + name: StringProperty(name="Name") + label: StringProperty(name="Label") + tab_name: StringProperty(name="Tab Name") + is_visible: BoolProperty(name="Is Visible in Tab", default=True, update=update_is_visible) + is_bookmarked: BoolProperty(name="Is Bookmarked", default=False, update=update_is_visible) if TYPE_CHECKING: - is_visible_in_tab: bool - is_visible_in_bookmarks: bool + name: str + tab_name: str + is_visible: bool is_bookmarked: bool @@ -628,7 +635,6 @@ class BIMProperties(PropertyGroup): name="Mass Unit", default="KILOGRAM", ) - time_unit: EnumProperty( items=[ ("SECOND", "Second", "Seconds"), @@ -638,9 +644,11 @@ class BIMProperties(PropertyGroup): ], name="Time Unit", default="HOUR", - ) + ) tab_visibilities: CollectionProperty(type=BIMTabVisibility, name="Tab Visibilities") - panel_properties: CollectionProperty(type=BIMPanelProperties, name="Panel Properties") + active_tab_visibility_index: IntProperty(name="Active Tab Visibility Index") + panel_visibilities: CollectionProperty(type=BIMPanelVisibility, name="Panel Properties") + active_panel_visibility_index: IntProperty(name="Active Panel Property Index") if TYPE_CHECKING: is_dirty: bool @@ -656,8 +664,10 @@ class BIMProperties(PropertyGroup): volume_unit: str mass_unit: str time_unit: str - tab_visibilities: bpy.types.bpy_prop_collection[BIMTabVisibility] - panel_properties: bpy.types.bpy_prop_collection[BIMPanelProperties] + tab_visibilities: bpy.types.bpy_prop_collection_idprop[BIMTabVisibility] + active_tab_visibility_index: int + panel_visibilities: bpy.types.bpy_prop_collection_idprop[BIMPanelVisibility] + active_panel_visibility_index: int class IfcParameter(PropertyGroup): @@ -791,12 +801,21 @@ class BIMFacet(PropertyGroup): ("!*=", "does not contain", ""), ], ) + filter_mode: EnumProperty( + items=[ + ("ADD", "Add", "Add results to the current selection"), + ("SUBTRACT", "Subtract", "Remove results from the current selection"), + ("FILTER", "Filter", "Filter the current selection"), + ], + default="ADD", + ) if TYPE_CHECKING: pset: str value: str type: str comparison: Literal["=", "!=", ">=", "<=", ">", "<", "*=", "!*="] + filter_mode: Literal["ADD", "SUBTRACT", "FILTER"] class BIMFilterGroup(PropertyGroup): diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index e9ab0b446c..c550c00872 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -36,19 +36,6 @@ import bonsai.bim import bonsai.tool as tool from ifcopenshell.util.file import IfcHeaderExtractor from bonsai.bim.prop import Attribute -from bonsai.bim.helper import ( - get_tab_names, - get_panel_tab_name, - should_show_panel, - get_tab_visibility, - set_tab_visibility, - get_panel_visibility, - is_panel_bookmarked, - get_panel_config, - get_all_tab_panels, - initialize_tab_visibilities, - initialize_panel_properties, -) from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty from bonsai.bim.module.pset.prop import IfcProperty from bonsai.bim.module.model.prop import ( @@ -255,6 +242,39 @@ class BIM_UL_generic(bpy.types.UIList): layout.label(text="", translate=False) +class BIM_UL_tab_visibilities(bpy.types.UIList): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: bpy.types.PropertyGroup, + item: bpy.types.PropertyGroup, + icon, + active_data, + active_propname, + ) -> None: + row = layout.row() + row.prop(item, "name", text="", emboss=False) + row.prop(item, "is_visible", text="", icon="HIDE_OFF" if item.is_visible else "HIDE_ON", emboss=False) + + +class BIM_UL_panel_visibilities(bpy.types.UIList): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: bpy.types.PropertyGroup, + item: bpy.types.PropertyGroup, + icon, + active_data, + active_propname, + ) -> None: + row = layout.row() + row.prop(item, "label", text="", emboss=False) + row.prop(item, "is_visible", text="", icon="HIDE_OFF" if item.is_visible else "HIDE_ON", emboss=False) + row.prop(item, "is_bookmarked", text="", icon="SOLO_ON" if item.is_bookmarked else "SOLO_OFF", emboss=False) + + class GizmoPreferencesDoor(bpy.types.PropertyGroup): """Property group for door gizmo visibility settings.""" @@ -724,11 +744,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): description="Custom suffix for the metadata blend file. Will be appended to the filename (without .ifc).", default=".ifc.metadata.blend", ) - user_ui_customization: BoolProperty( - name="User UI Customization", - description="Enable user interface customization features (hide/show tabs and panels, bookmark panels) and save the session settings as part of the metadata blend file", - default=False, - ) if TYPE_CHECKING: svg2pdf_command: str @@ -770,7 +785,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): chain_filter_with_set_operations: bool default_filter_with_set_operations_for_globalid_and_class: bool save_metadata_blend_file: bool - user_ui_customization: bool def draw(self, context: bpy.types.Context) -> None: layout = self.layout @@ -837,7 +851,11 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props} # Add special gizmos not in dimension_gizmo_props gizmo_prop_names.update(("swing_arc", "flip_arc")) - for prop in door_gizmos.__annotations__: + try: + annotations = door_gizmos.__annotations__ + except AttributeError: + annotations = type(door_gizmos).__annotations__ + for prop in annotations: if prop in gizmo_prop_names: layout.prop(door_gizmos, prop) @@ -846,7 +864,11 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): window_gizmos = self.gizmos.window gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props} - for prop in window_gizmos.__annotations__: + try: + annotations = window_gizmos.__annotations__ + except AttributeError: + annotations = type(window_gizmos).__annotations__ + for prop in annotations: if prop in gizmo_prop_names: layout.prop(window_gizmos, prop) @@ -857,7 +879,11 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props} # Add special gizmos not in dimension_gizmo_props special_gizmo_names = {"lock", "plus", "minus", "cycle"} - for prop in stair_gizmos.__annotations__: + try: + annotations = stair_gizmos.__annotations__ + except AttributeError: + annotations = type(stair_gizmos).__annotations__ + for prop in annotations: if prop in gizmo_prop_names or prop in special_gizmo_names: layout.prop(stair_gizmos, prop) @@ -966,9 +992,26 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): row = layout.row() row.separator() row.prop(self, "metadata_blend_file_suffix") - row = layout.row() - row.separator() - row.prop(self, "user_ui_customization") + + bprops = tool.Blender.get_bim_props() + if tab_visibilities := bprops.tab_visibilities: + row = layout.row() + row.operator("bim.reset_ui_layout", icon="LOOP_BACK") + row = layout.row(align=True) + row.template_list( + "BIM_UL_tab_visibilities", "", bprops, "tab_visibilities", bprops, "active_tab_visibility_index" + ) + row.template_list( + "BIM_UL_panel_visibilities", + "", + bprops, + "panel_visibilities", + bprops, + "active_panel_visibility_index", + ) + else: + row = layout.row() + row.operator("bim.manage_tab_visibility", icon="PREFERENCES") # Scene panel groups @@ -984,78 +1027,31 @@ class BIM_PT_tabs(Panel): def draw(self, context): if not UIData.is_loaded: UIData.load() - is_ifc_project = bool(tool.Ifc.get()) aprops = tool.Blender.get_area_props(context) addon_prefs = tool.Blender.get_addon_preferences() - ifc_icon = f"{UIData.data['tabs_icon_color_mode']}_ifc" - split = self.layout.split(factor=0.9) - col_left = split.column(align=True) - row_left = col_left.row(align=True) - row_left.alignment = "CENTER" - if get_tab_visibility("PROJECT"): - row_left.operator( - "bim.set_tab", - text="", - emboss=aprops.tab == "PROJECT", - icon_value=bonsai.bim.icons[ifc_icon].icon_id, - ).tab = "PROJECT" - if get_tab_visibility("OBJECT"): - self.draw_tab_entry(row_left, "FILE_3D", "OBJECT", is_ifc_project, aprops.tab == "OBJECT") - if get_tab_visibility("GEOMETRY"): - self.draw_tab_entry(row_left, "MATERIAL", "GEOMETRY", is_ifc_project, aprops.tab == "GEOMETRY") - if get_tab_visibility("DRAWINGS"): - self.draw_tab_entry(row_left, "DOCUMENTS", "DRAWINGS", is_ifc_project, aprops.tab == "DRAWINGS") - if get_tab_visibility("SERVICES"): - self.draw_tab_entry(row_left, "NETWORK_DRIVE", "SERVICES", is_ifc_project, aprops.tab == "SERVICES") - if get_tab_visibility("STRUCTURE"): - self.draw_tab_entry(row_left, "EDITMODE_HLT", "STRUCTURE", is_ifc_project, aprops.tab == "STRUCTURE") - if get_tab_visibility("SCHEDULING"): - self.draw_tab_entry(row_left, "NLA", "SCHEDULING", is_ifc_project, aprops.tab == "SCHEDULING") - if get_tab_visibility("FM"): - self.draw_tab_entry(row_left, "PACKAGE", "FM", True, aprops.tab == "FM") - if get_tab_visibility("QUALITY"): - self.draw_tab_entry(row_left, "COMMUNITY", "QUALITY", True, aprops.tab == "QUALITY") - if ( - addon_prefs.save_metadata_blend_file - and addon_prefs.user_ui_customization - and get_tab_visibility("BOOKMARK") - ): - self.draw_tab_entry(row_left, "SOLO_ON", "BOOKMARK", True, aprops.tab == "BOOKMARK") - row_left.operator("bim.switch_tab", text="", emboss=False, icon="UV_SYNC_SELECT") + row = self.layout.row() + row.alignment = "CENTER" + for tab in UIData.data["tabs"]: + self.draw_tab_entry(row, tab[1], tab[0], tab[2], aprops.tab == tab[0]) + row.operator("bim.switch_tab", text="", emboss=False, icon="UV_SYNC_SELECT") - row_left = col_left.row(align=True) + row = self.layout.row() # Yes, that's right. - row_left.alignment = "CENTER" - row_left.scale_y = 0.2 + row.alignment = "CENTER" + row.scale_y = 0.2 - if not (addon_prefs.save_metadata_blend_file and addon_prefs.user_ui_customization): - row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) - - for tab in get_tab_names(): + for tab in UIData.data["tabs"]: # Draw a little underscore below the active tab icon. - if get_tab_visibility(tab): - if aprops.tab == tab: - row_left.prop(aprops, "active_tab", text="", icon="BLANK1") - else: - row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) - row_left.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) # space for Switch - col_right = split.column(align=True) - row_right = col_right.row(align=True) - row_right.alignment = "RIGHT" + if aprops.tab == tab[0]: + row.prop(aprops, "active_tab", text="", icon="BLANK1") + else: + row.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) + row.prop(aprops, "inactive_tab", text="", icon="BLANK1", emboss=False) # space for Switch - if addon_prefs.save_metadata_blend_file and addon_prefs.user_ui_customization: - row_right.operator("bim.manage_tab_visibility", icon="PREFERENCES", text="") - - row = self.layout.row(align=True) + row = self.layout.row() row.prop(aprops, "tab", text="") - if addon_prefs.save_metadata_blend_file and addon_prefs.user_ui_customization: - for tab in get_tab_names(): - if get_tab_visibility(tab): - if aprops.tab == tab: - row.operator("bim.manage_tab_panels", text="", icon="PREFERENCES").tab_name = tab - if bonsai.REINSTALLED_BBIM_VERSION: box = self.layout.box() box.alert = True @@ -1121,7 +1117,10 @@ class BIM_PT_tabs(Panel): def draw_tab_entry(self, row, icon, tab_name, enabled=True, highlight=True): tab_entry = row.row(align=True) - tab_entry.operator("bim.set_tab", text="", emboss=highlight, icon=icon).tab = tab_name + if isinstance(icon, int): + tab_entry.operator("bim.set_tab", text="", emboss=highlight, icon_value=icon).tab = tab_name + else: + tab_entry.operator("bim.set_tab", text="", emboss=highlight, icon=icon).tab = tab_name tab_entry.enabled = enabled @@ -1135,9 +1134,7 @@ class BIM_PT_tab_new_project_wizard(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if not tool.Blender.is_tab(context, cls.bim_tab_name): + if not tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return False bim_props = tool.Blender.get_bim_props() pprops = tool.Project.get_project_props() @@ -1161,20 +1158,16 @@ class BIM_PT_tab_project_info(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): bim_props = tool.Blender.get_bim_props() pprops = tool.Project.get_project_props() if pprops.is_loading: return True elif tool.Ifc.get() or bim_props.ifc_file: return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): - layout = self.layout - layout.label(text="This is the Project Info panel.") + pass class BIM_PT_tab_spatial(Panel): @@ -1187,11 +1180,8 @@ class BIM_PT_tab_spatial(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1207,11 +1197,8 @@ class BIM_PT_tab_project_setup(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1228,11 +1215,8 @@ class BIM_PT_tab_stakeholders(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1248,11 +1232,8 @@ class BIM_PT_tab_collaboration(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1269,11 +1250,8 @@ class BIM_PT_tab_grouping_and_filtering(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1297,11 +1275,8 @@ class BIM_PT_tab_geometry(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1317,11 +1292,8 @@ class BIM_PT_tab_status(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1337,11 +1309,8 @@ class BIM_PT_tab_qto(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1357,11 +1326,8 @@ class BIM_PT_tab_resources(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1377,11 +1343,8 @@ class BIM_PT_tab_cost(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1397,11 +1360,8 @@ class BIM_PT_tab_sequence(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1417,11 +1377,8 @@ class BIM_PT_tab_structural(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1437,11 +1394,8 @@ class BIM_PT_tab_services(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1457,11 +1411,8 @@ class BIM_PT_tab_lighting(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1477,11 +1428,8 @@ class BIM_PT_tab_zones(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1497,11 +1445,8 @@ class BIM_PT_tab_solar_analysis(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1517,11 +1462,8 @@ class BIM_PT_tab_quality_control(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1537,11 +1479,8 @@ class BIM_PT_tab_clash_detection(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1558,11 +1497,8 @@ class BIM_PT_tab_sandbox(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): row = self.layout.row() @@ -1581,11 +1517,9 @@ class BIM_PT_tab_object_metadata(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False props = tool.Project.get_project_props() if ( - tool.Blender.is_tab(context, cls.bim_tab_name) + tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get() and (obj := context.active_object) # Hide links empty handles. @@ -1596,7 +1530,6 @@ class BIM_PT_tab_object_metadata(Panel): ) ): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1613,16 +1546,13 @@ class BIM_PT_tab_placement(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False if ( - tool.Blender.is_tab(context, cls.bim_tab_name) + tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get() and (obj := context.active_object) and tool.Ifc.get_entity(obj) ): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1639,11 +1569,8 @@ class BIM_PT_tab_representations(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1661,11 +1588,8 @@ class BIM_PT_tab_geometric_relationships(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1683,11 +1607,8 @@ class BIM_PT_tab_parametric_geometry(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1704,11 +1625,8 @@ class BIM_PT_tab_object_materials(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1725,11 +1643,8 @@ class BIM_PT_tab_materials(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1746,11 +1661,8 @@ class BIM_PT_tab_styles(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1767,11 +1679,8 @@ class BIM_PT_tab_profiles(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1788,11 +1697,8 @@ class BIM_PT_tab_sheets(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1809,11 +1715,8 @@ class BIM_PT_tab_drawings(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1830,11 +1733,8 @@ class BIM_PT_tab_schedules(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1851,11 +1751,8 @@ class BIM_PT_tab_references(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1873,11 +1770,8 @@ class BIM_PT_tab_misc(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name) and tool.Ifc.get(): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1894,11 +1788,8 @@ class BIM_PT_tab_handover(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1915,11 +1806,8 @@ class BIM_PT_tab_operations(Panel): @classmethod def poll(cls, context): - if not should_show_panel(cls.bl_idname, cls.bim_tab_name, context): - return False - if tool.Blender.is_tab(context, cls.bim_tab_name): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname): return True - return tool.Blender.is_tab(context, "BOOKMARK") def draw(self, context): pass @@ -1960,8 +1848,8 @@ class UIData: def load(cls): cls.data = { "version": cls.version(), - "tabs_icon_color_mode": cls.icon_color_mode("user_interface.wcol_regular.text"), "menu_icon_color_mode": cls.icon_color_mode("user_interface.wcol_menu.text"), + "tabs": cls.tabs(), } cls.is_loaded = True @@ -1973,6 +1861,28 @@ class UIData: def icon_color_mode(cls, color_path): return tool.Blender.detect_icon_color_mode(color_path) + @classmethod + def tabs(cls): + hidden_tabs = [t.name for t in tool.Blender.get_bim_props().tab_visibilities if not t.is_visible] + color_mode = cls.icon_color_mode("user_interface.wcol_regular.text") + is_ifc_project = bool(tool.Ifc.get()) + return [ + tab + for tab in [ + ("PROJECT", bonsai.bim.icons[f"{color_mode}_ifc"].icon_id, True), + ("OBJECT", "FILE_3D", is_ifc_project), + ("GEOMETRY", "MATERIAL", is_ifc_project), + ("DRAWINGS", "DOCUMENTS", is_ifc_project), + ("SERVICES", "NETWORK_DRIVE", is_ifc_project), + ("STRUCTURE", "EDITMODE_HLT", is_ifc_project), + ("SCHEDULING", "NLA", is_ifc_project), + ("FM", "PACKAGE", True), + ("QUALITY", "COMMUNITY", True), + ("BOOKMARK", "SOLO_ON", is_ifc_project), + ] + if tab[0] not in hidden_tabs + ] + def draw_statusbar(self, context): if not UIData.is_loaded: diff --git a/src/bonsai/bonsai/core/document.py b/src/bonsai/bonsai/core/document.py index e82360dbb9..ac49fee10d 100644 --- a/src/bonsai/bonsai/core/document.py +++ b/src/bonsai/bonsai/core/document.py @@ -20,7 +20,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: - import bpy import ifcopenshell import bonsai.tool as tool @@ -28,102 +27,82 @@ if TYPE_CHECKING: def load_project_documents(document: tool.Document) -> None: document.clear_document_tree() document.import_project_documents() - document.clear_breadcrumbs() document.enable_editing_ui() -def load_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: - document_tool.clear_document_tree() - document_tool.import_subdocuments(document) - document_tool.import_references(document) - document_tool.disable_editing_document() - document_tool.add_breadcrumb(document) - - -def load_parent_document(document: tool.Document) -> None: - document.clear_document_tree() - document.remove_latest_breadcrumb() - parent = document.get_active_breadcrumb() - if parent: - document.import_subdocuments(parent) - document.import_references(parent) - document.disable_editing_document() - else: - document.import_project_documents() - - def disable_document_editing_ui(document: tool.Document) -> None: document.disable_editing_ui() document.disable_editing_document() -def enable_editing_document(document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: - document_tool.import_document_attributes(document) - document_tool.set_active_document(document) +def disable_object_document_editing_ui(document: tool.Document) -> None: + document.disable_object_editing_ui() + + +def enable_editing_document(document: tool.Document, ifc_document: ifcopenshell.entity_instance) -> None: + document.set_active_document(ifc_document) + document.import_document_attributes(ifc_document) def disable_editing_document(document: tool.Document) -> None: - document.disable_editing_document() + document.clear_active_document() + document.clear_document_attributes() -def add_information(ifc: tool.Ifc, document: tool.Document) -> None: +def add_information(ifc: tool.Ifc, document: tool.Document, parent=None) -> ifcopenshell.entity_instance: document.clear_document_tree() - parent = document.get_active_breadcrumb() + + if parent is None: + parent = document.get_default_parent_for_information() + information = ifc.run("document.add_information", parent=parent) ifc.run("document.add_reference", information=information) - if parent: - document.import_subdocuments(parent) - document.import_references(parent) - else: - document.import_project_documents() + + if document.is_document_information(parent): + document.expand_document(parent) + + document.import_project_documents() + return information def add_reference(ifc: tool.Ifc, document: tool.Document) -> None: - parent = document.get_active_breadcrumb() - assert parent - ifc.run("document.add_reference", information=parent) + parent = document.get_selected_document_information() + + if parent: + reference = ifc.run("document.add_reference", information=parent) + reference.Location = "" + document.expand_document(parent) + + document.import_project_documents() + + +def edit_document(ifc: tool.Ifc, document: tool.Document, ifc_document: ifcopenshell.entity_instance) -> None: + attributes = document.export_document_attributes() + if document.is_document_information(ifc_document): + ifc.run("document.edit_information", information=ifc_document, attributes=attributes) + else: + ifc.run("document.edit_reference", reference=ifc_document, attributes=attributes) + document.disable_editing_document() document.clear_document_tree() - document.import_subdocuments(parent) - document.import_references(parent) + document.import_project_documents() -def edit_document(ifc: tool.Ifc, document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: - attributes = document_tool.export_document_attributes() - if document_tool.is_document_information(document): - ifc.run("document.edit_information", information=document, attributes=attributes) +def remove_document(ifc: tool.Ifc, document: tool.Document, ifc_document: ifcopenshell.entity_instance) -> None: + document.clear_document_tree() + if document.is_document_information(ifc_document): + ifc.run("document.remove_information", information=ifc_document) else: - ifc.run("document.edit_reference", reference=document, attributes=attributes) - document_tool.disable_editing_document() - document_tool.clear_document_tree() - parent = document_tool.get_active_breadcrumb() - if parent: - document_tool.import_subdocuments(parent) - document_tool.import_references(parent) - else: - document_tool.import_project_documents() - - -def remove_document(ifc: tool.Ifc, document_tool: tool.Document, document: ifcopenshell.entity_instance) -> None: - document_tool.clear_document_tree() - if document_tool.is_document_information(document): - ifc.run("document.remove_information", information=document) - else: - ifc.run("document.remove_reference", reference=document) - parent = document_tool.get_active_breadcrumb() - if parent: - document_tool.import_subdocuments(parent) - document_tool.import_references(parent) - else: - document_tool.import_project_documents() + ifc.run("document.remove_reference", reference=ifc_document) + document.import_project_documents() def assign_document( - ifc: tool.Ifc, product: ifcopenshell.entity_instance, document: ifcopenshell.entity_instance + ifc: tool.Ifc, product: ifcopenshell.entity_instance, ifc_document: ifcopenshell.entity_instance ) -> None: - ifc.run("document.assign_document", products=[product], document=document) + ifc.run("document.assign_document", products=[product], document=ifc_document) def unassign_document( - ifc: tool.Ifc, product: ifcopenshell.entity_instance, document: ifcopenshell.entity_instance + ifc: tool.Ifc, product: ifcopenshell.entity_instance, ifc_document: ifcopenshell.entity_instance ) -> None: - ifc.run("document.unassign_document", products=[product], document=document) + ifc.run("document.unassign_document", products=[product], document=ifc_document) 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/tool.py b/src/bonsai/bonsai/core/tool.py index d8e4dd2396..36afd7d66c 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -287,21 +287,29 @@ class Debug: @interface class Document: - def add_breadcrumb(cls, document): pass - def clear_breadcrumbs(cls): pass def clear_document_tree(cls): pass def disable_editing_document(cls): pass + def disable_object_editing_ui(cls): pass def disable_editing_ui(cls): pass def enable_editing_ui(cls): pass def export_document_attributes(cls): pass - def get_active_breadcrumb(cls): pass def import_document_attributes(cls, document): pass def import_project_documents(cls): pass - def import_references(cls, document): pass - def import_subdocuments(cls, document): pass def is_document_information(cls, document): pass - def remove_latest_breadcrumb(cls): pass def set_active_document(cls, document): pass + def clear_active_document(cls): pass + def clear_document_attributes(cls): pass + def expand_document(cls, document): pass + def get_default_parent_for_information(cls): pass + def get_selected_document_information(cls): pass + def get_document_information_id(cls, document): pass + def set_document_information_id(cls, document, value): pass + def get_external_reference_id(cls, reference): pass + def set_external_reference_id(cls, reference, value): pass + def get_document_references(cls, document): pass + def refresh_document_data(cls): pass + def load_document_objects_into_props(cls, document_id): pass + def update_document_objects(cls, document_id): pass @interface diff --git a/src/bonsai/bonsai/core/type.py b/src/bonsai/bonsai/core/type.py index 226f00c461..2918e841f7 100644 --- a/src/bonsai/bonsai/core/type.py +++ b/src/bonsai/bonsai/core/type.py @@ -19,6 +19,7 @@ from __future__ import annotations import bonsai.core.geometry from typing import TYPE_CHECKING, Optional +import ifcopenshell.util.element if TYPE_CHECKING: import bpy @@ -32,14 +33,34 @@ 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): - pass # for now, representation regeneration handled by API listeners + # Restore the instance's CardinalPoint to the new material usage + if instance_cardinal_point is not None: + new_instance_material = ifcopenshell.util.element.get_material(element) + 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/core/unit.py b/src/bonsai/bonsai/core/unit.py index 9594c2b566..fde75297b1 100644 --- a/src/bonsai/bonsai/core/unit.py +++ b/src/bonsai/bonsai/core/unit.py @@ -66,8 +66,6 @@ def assign_scene_units(ifc: type[tool.Ifc], unit: type[tool.Unit]) -> None: else: timeunit = ifc.run("unit.add_conversion_based_unit", name=time_unit_name.lower()) units += [massunit, timeunit] - print("Add mass and time units:", unit.add_mass_and_time_units()) - print("Assigning units:", units) ifc.run("unit.assign_unit", units=units) diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 97b161a11b..24cd5071fa 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -161,11 +161,19 @@ class Blender(bonsai.core.tool.Blender): screen.BIMAreaProperties.add() @classmethod - def is_tab(cls, context: bpy.types.Context, tab: str) -> bool: + def should_show_panel(cls, context: bpy.types.Context, tab: str, panel: str) -> bool: aprops = cls.get_area_props(context) if aprops.path_from_id() == "BIMAreaProperties" and context.area.spaces.active.search_filter: return True - return aprops.tab == tab + if (is_bookmark_tab := aprops.tab == "BOOKMARK") or aprops.tab == tab: + bprops = tool.Blender.get_bim_props() + if not (panel_visibility := bprops.panel_visibilities.get(panel)): + return not is_bookmark_tab + if is_bookmark_tab: + if panel_visibility.is_bookmarked: + return True + elif panel_visibility.is_visible: + return True @classmethod def is_default_scene(cls) -> bool: @@ -382,17 +390,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 @@ -742,7 +747,11 @@ class Blender(bonsai.core.tool.Blender): # Yes, accessing items through annotations is a bit hacky # but it's the only way to get the dynamic enum items # besides providing them to get_enum_safe explicitly. - prop_keywords = props.__annotations__[prop_name].keywords + try: + annotations = props.__annotations__ + except AttributeError: + annotations = type(props).__annotations__ + prop_keywords = annotations[prop_name].keywords items = prop_keywords.get("items") if items is None: return None @@ -1460,7 +1469,10 @@ class Blender(bonsai.core.tool.Blender): def override_scene_panel(cls, original_panel: bpy.types.Panel) -> None: @classmethod def poll_check_blender_tab(cls, context): - return tool.Blender.is_tab(context, "BLENDER") + aprops = tool.Blender.get_area_props(context) + if aprops.path_from_id() == "BIMAreaProperties" and context.area.spaces.active.search_filter: + return True + return aprops.tab == "BLENDER" polls = bonsai.bim.original_scene_panels_polls diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index e91c52d3d0..7c46135b26 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -44,8 +44,6 @@ class Collector(bonsai.core.tool.Collector): # Note that tool.Geometry.is_locked is only checked within the if # statements for efficiency as it is a slow check. tool.Geometry.lock_scale(obj) - if element.is_a("IfcSlab"): - tool.Geometry.lock_rotation(obj, x=True) if element.is_a("IfcGridAxis"): if tool.Geometry.is_locked(element): diff --git a/src/bonsai/bonsai/tool/document.py b/src/bonsai/bonsai/tool/document.py index 3a2f89aa94..bf8e4d17b8 100644 --- a/src/bonsai/bonsai/tool/document.py +++ b/src/bonsai/bonsai/tool/document.py @@ -22,6 +22,8 @@ import ifcopenshell.util.system import bonsai.bim.helper import bonsai.core.tool import bonsai.tool as tool +import json +from natsort import natsorted from typing import Any, Union, TYPE_CHECKING if TYPE_CHECKING: @@ -33,17 +35,6 @@ class Document(bonsai.core.tool.Document): def get_document_props(cls) -> BIMDocumentProperties: return bpy.context.scene.BIMDocumentProperties - @classmethod - def add_breadcrumb(cls, document: ifcopenshell.entity_instance) -> None: - props = cls.get_document_props() - new = props.breadcrumbs.add() - new.name = str(document.id()) - - @classmethod - def clear_breadcrumbs(cls) -> None: - props = cls.get_document_props() - props.breadcrumbs.clear() - @classmethod def clear_document_tree(cls) -> None: props = cls.get_document_props() @@ -54,6 +45,11 @@ class Document(bonsai.core.tool.Document): props = cls.get_document_props() props.active_document_id = 0 + @classmethod + def disable_object_editing_ui(cls) -> None: + props = cls.get_document_props() + props.is_object_editing = False + @classmethod def disable_editing_ui(cls) -> None: props = cls.get_document_props() @@ -69,18 +65,15 @@ class Document(bonsai.core.tool.Document): props = cls.get_document_props() return bonsai.bim.helper.export_attributes(props.document_attributes) - @classmethod - def get_active_breadcrumb(cls) -> Union[ifcopenshell.entity_instance, None]: - props = cls.get_document_props() - if len(props.breadcrumbs): - return tool.Ifc.get().by_id(int(props.breadcrumbs[-1].name)) - @classmethod def import_document_attributes(cls, document: ifcopenshell.entity_instance) -> None: props = cls.get_document_props() props.document_attributes.clear() - def callback(attr_name: str, _, data: dict[str, Any]) -> Union[bool, None]: + def callback(attr_name: str, attr_value: Any, data: dict[str, Any]) -> Union[bool, None]: + if attr_name == "Location" and attr_value is None: + data[attr_name] = "" + return True if attr_name != "Name": return None # Proceed normally @@ -100,52 +93,132 @@ class Document(bonsai.core.tool.Document): def import_project_documents(cls) -> None: props = cls.get_document_props() props.documents.clear() - project = tool.Ifc.get().by_type("IfcProject")[0] + file = tool.Ifc.get() + try: + expanded_documents = json.loads(props.json_string) + except (AttributeError, json.JSONDecodeError): + expanded_documents = [] + + project = file.by_type("IfcProject")[0] if file.by_type("IfcProject") else None + if not project: + return + + document_children = {} + + for rel in file.by_type("IfcDocumentInformationRelationship"): + parent_id = rel.RelatingDocument.id() + if parent_id not in document_children: + document_children[parent_id] = [] + + for child in rel.RelatedDocuments: + document_children[parent_id].append(child) + + is_ifc2x3 = file.schema == "IFC2X3" + + if is_ifc2x3: + for ref in file.by_type("IfcDocumentReference"): + if ref.ReferenceToDocument: + parent = ref.ReferenceToDocument[0] + parent_id = parent.id() + if parent_id not in document_children: + document_children[parent_id] = [] + document_children[parent_id].append(ref) + else: + for ref in file.by_type("IfcDocumentReference"): + if ref.ReferencedDocument: + parent = ref.ReferencedDocument + parent_id = parent.id() + if parent_id not in document_children: + document_children[parent_id] = [] + document_children[parent_id].append(ref) + + root_documents = [] for rel in project.HasAssociations or []: if rel.is_a("IfcRelAssociatesDocument") and rel.RelatingDocument.is_a("IfcDocumentInformation"): - element = rel.RelatingDocument - new = props.documents.add() - new.ifc_definition_id = element.id() - new["name"] = element.Name or "Unnamed" - new.is_information = True - new["identification"] = cls.get_document_information_id(element) + is_child = False + for children in document_children.values(): + if rel.RelatingDocument in children: + is_child = True + break + + if not is_child: + root_documents.append(rel.RelatingDocument) + + root = props.documents.add() + root.ifc_definition_id = -project.id() + root.document_type = "PROJECT" + root.name = f"Project Documents ({project.Name or 'Unnamed Project'})" + root.identification = "" + root.location = "" + root.tree_depth = 0 + root.has_children = bool(root_documents) + + root_id = -project.id() + + root.is_expanded = root_id not in expanded_documents + + if root.is_expanded: + root_documents = natsorted( + root_documents, key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or "") + ) + + for doc in root_documents: + cls._process_document(doc, props, document_children, expanded_documents, 1) @classmethod - def import_references(cls, document: ifcopenshell.entity_instance) -> None: - props = cls.get_document_props() - is_ifc2x3 = tool.Ifc.get_schema() == "IFC2X3" - references = cls.get_document_references(document) - for element in references: - new = props.documents.add() - new.ifc_definition_id = element.id() - # Use Description + Location instead of Name as IFC has a restriction - # for IfcDocumentReference to have Name only if it has no ReferencedDocument. - name = " - ".join([x for x in [element.Description, element.Location] if x]) - new["name"] = name or "Unnamed" - new["identification"] = cls.get_external_reference_id(element) - new.is_information = False + def _process_document(cls, document, props, document_children, expanded_documents, depth): + new = props.documents.add() + new.ifc_definition_id = document.id() + new.document_type = "INFORMATION" if document.is_a("IfcDocumentInformation") else "REFERENCE" + new.tree_depth = depth - @classmethod - def import_subdocuments(cls, document: ifcopenshell.entity_instance) -> None: - props = cls.get_document_props() - if document.IsPointer: - for element in document.IsPointer[0].RelatedDocuments or []: - new = props.documents.add() - new.ifc_definition_id = element.id() - new["name"] = element.Name or "Unnamed" - new.is_information = True - new["identification"] = cls.get_document_information_id(element) or "*" + new.name = document.Name or "" + new.identification = ( + cls.get_document_information_id(document) + if new.document_type == "INFORMATION" + else cls.get_external_reference_id(document) + ) + new.identification = new.identification or "" + new.description = document.Description or "" + new.location = document.Location or "" + + if new.document_type == "INFORMATION": + new.name = document.Name or "Unnamed" + + elif new.document_type == "REFERENCE": + file = document.file + if file.schema == "IFC2X3": + if document.ReferenceToDocument and not new.name: + new.name = document.ReferenceToDocument[0].Name or "" + else: + if document.ReferencedDocument and not new.name: + new.name = document.ReferencedDocument.Name or "" + + doc_id = document.id() + has_children = doc_id in document_children and bool(document_children[doc_id]) + new.has_children = has_children + new.is_expanded = doc_id in expanded_documents + + if has_children and new.is_expanded: + children = document_children[doc_id] + + info_children = natsorted( + [d for d in children if d.is_a("IfcDocumentInformation")], + key=lambda doc: (cls.get_document_information_id(doc) or "", doc.Name or ""), + ) + + ref_children = natsorted( + [d for d in children if not d.is_a("IfcDocumentInformation")], + key=lambda doc: (cls.get_external_reference_id(doc) or "", doc.Description or doc.Name or ""), + ) + + for child in info_children + ref_children: + cls._process_document(child, props, document_children, expanded_documents, depth + 1) @classmethod def is_document_information(cls, document: ifcopenshell.entity_instance) -> bool: return document.is_a("IfcDocumentInformation") - @classmethod - def remove_latest_breadcrumb(cls) -> None: - props = cls.get_document_props() - if len(props.breadcrumbs): - props.breadcrumbs.remove(len(props.breadcrumbs) - 1) - @classmethod def set_active_document(cls, document: ifcopenshell.entity_instance) -> None: props = cls.get_document_props() @@ -179,3 +252,65 @@ class Document(bonsai.core.tool.Document): if document.file.schema == "IFC2X3": return document.DocumentReferences or () return document.HasDocumentReferences + + @classmethod + def clear_active_document(cls) -> None: + props = cls.get_document_props() + props.active_document_id = 0 + + @classmethod + def clear_document_attributes(cls) -> None: + props = cls.get_document_props() + props.document_attributes.clear() + + @classmethod + def expand_document(cls, document: ifcopenshell.entity_instance) -> None: + props = cls.get_document_props() + try: + expanded_docs = json.loads(props.json_string) + except (AttributeError, json.JSONDecodeError): + expanded_docs = [] + + if document.id() not in expanded_docs: + expanded_docs.append(document.id()) + props.json_string = json.dumps(expanded_docs) + + @classmethod + def get_default_parent_for_information(cls) -> Union[ifcopenshell.entity_instance, None]: + file = tool.Ifc.get() + projects = file.by_type("IfcProject") + return projects[0] if projects else None + + @classmethod + def get_selected_document_information(cls) -> Union[ifcopenshell.entity_instance, None]: + props = cls.get_document_props() + + if props.active_document and props.active_document.document_type == "INFORMATION": + file = tool.Ifc.get() + return file.by_id(props.active_document.ifc_definition_id) + return None + + @classmethod + def refresh_document_data(cls) -> None: + import bonsai.bim.module.document.data as document_data + + document_data.DocumentData.is_loaded = False + document_data.DocumentData.load() + + @classmethod + def load_document_objects_into_props(cls, document_id: int) -> None: + import bonsai.bim.module.document.data as document_data + + document_data.DocumentData.load_document_objects_into_props(document_id) + + @classmethod + def update_document_objects(cls, document_id: Union[int, None] = None) -> None: + cls.refresh_document_data() + + if document_id is None: + props = cls.get_document_props() + if props.active_document and props.active_document.ifc_definition_id > 0: + document_id = props.active_document.ifc_definition_id + + if document_id: + cls.load_document_objects_into_props(document_id) diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index e28ceacaec..529d06a27b 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -1021,7 +1021,7 @@ class Loader(bonsai.core.tool.Loader): elif material.is_a("IfcMaterialLayerSetUsage"): usage = material layer_set = material.ForLayerSet - offset = usage.OffsetFromReferenceLine * cls.unit_scale + offset = usage.OffsetFromReferenceLine sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1 elif material.is_a("IfcMaterialLayerSet"): usage = None @@ -1030,61 +1030,168 @@ class Loader(bonsai.core.tool.Loader): sense_factor = 1 else: return mesh + if len(layer_set.MaterialLayers) == 1: return mesh + + # Get mesh bounds + if len(mesh.vertices) > 0: + z_coords = [v.co.z for v in mesh.vertices] + mesh_z_min = min(z_coords) + mesh_z_max = max(z_coords) + bm = bmesh.new() bm.from_mesh(mesh) + prev_co = None + advance_direction = None + if not usage: - sense_factor = 1 # Assume the extrusion vector points in the direction sense + sense_factor = 1 no = cls.get_extrusion_vector(element).normalized() co = Vector((0.0, 0.0, offset)) + advance_direction = no elif usage.LayerSetDirection == "AXIS2": - co = Vector((0.0, offset, 0.0)) - no = cls.get_extrusion_vector(element).normalized() - no = no.cross(Vector([1.0, 0.0, 0.0])) + # Get local extrusion direction + local_extrusion = Vector([0.0, 0.0, 1.0]) + if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): + for item in ifcopenshell.util.representation.resolve_representation(body).Items: + while item.is_a("IfcBooleanResult"): + item = item.FirstOperand + if item.is_a("IfcExtrudedAreaSolid"): + local_extrusion = Vector(item.ExtrudedDirection.DirectionRatios).normalized() + break + + # Thickness direction: perpendicular to extrusion and length + thickness_dir = local_extrusion.cross(Vector([1.0, 0.0, 0.0])).normalized() + if thickness_dir.y < 0: + thickness_dir = -thickness_dir + + no = thickness_dir + + # Find start point by projecting vertices onto thickness direction + if len(mesh.vertices) > 0: + projections = [Vector(v.co).dot(no) for v in mesh.vertices] + min_proj = min(projections) + max_proj = max(projections) + + centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices) + centroid_proj = centroid.dot(no) + + if sense_factor == 1: + start_proj = min_proj + else: + start_proj = max_proj + + offset_dist = start_proj - centroid_proj + co = centroid + no * offset_dist + + actual_mesh_height = max_proj - min_proj + else: + co = Vector((0.0, 0.0, 0.0)) + + advance_direction = thickness_dir elif usage.LayerSetDirection == "AXIS3": - co = Vector((0.0, 0.0, offset)) - no = cls.get_extrusion_vector(element).normalized() + # AXIS3 layers go through slab thickness (local Z) no = Vector([0.0, 0.0, 1.0]) + + # Find start point by projecting vertices onto Z direction + if len(mesh.vertices) > 0: + projections = [Vector(v.co).dot(no) for v in mesh.vertices] + min_proj = min(projections) + max_proj = max(projections) + + centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices) + centroid_proj = centroid.dot(no) + + if sense_factor == 1: + start_proj = min_proj + else: + start_proj = max_proj + + offset = start_proj - centroid_proj + co = centroid + no * offset + + actual_mesh_height = max_proj - min_proj + else: + co = Vector((0.0, 0.0, 0.0)) + + advance_direction = no elif usage.LayerSetDirection == "AXIS1": co = Vector((0.0, 0.0, offset)) no = cls.get_extrusion_vector(element).normalized() no = Vector([1.0, 0.0, 0.0]) - no *= sense_factor - # Cache this + advance_direction = no + + # Apply DirectionSense + if usage and usage.LayerSetDirection == "AXIS2": + if sense_factor == -1: + advance_direction = -advance_direction + test_normal = -no + else: + test_normal = no + elif usage and usage.LayerSetDirection == "AXIS1": + no = no * sense_factor + advance_direction = advance_direction * sense_factor + test_normal = no + elif usage and usage.LayerSetDirection == "AXIS3": + if sense_factor == -1: + advance_direction = -advance_direction + test_normal = -no + else: + test_normal = no + else: + test_normal = no + + # Cache material styles body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") styles = {} has_layer_styles = False for i, material in enumerate(mesh.materials): if style := tool.Ifc.get_entity(material): styles[style] = i + + layer_list = list(enumerate(layer_set.MaterialLayers)) + + # Calculate scale factor + total_layer_thickness = sum(layer.LayerThickness for _, layer in layer_list) + + if 'actual_mesh_height' not in locals(): + actual_mesh_height = mesh_z_max - mesh_z_min if len(mesh.vertices) > 0 else total_layer_thickness + + thickness_scale = actual_mesh_height / total_layer_thickness if total_layer_thickness > 0 else 1.0 + last_i = len(layer_set.MaterialLayers) - 1 - for i, layer in enumerate(layer_set.MaterialLayers): - if i != last_i: + + for idx, (original_i, layer) in enumerate(layer_list): + if idx != last_i: prev_co = co.copy() - co += no * layer.LayerThickness * cls.unit_scale + advance_vector = advance_direction * layer.LayerThickness * thickness_scale + co += advance_vector + 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: + + if idx == last_i: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace): center = face.calc_center_median() - if (center - co).dot(no) >= 0: + if (center - co).dot(test_normal) >= 0: face.material_index = material_index has_layer_styles = True else: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace): center = face.calc_center_median() - if (center - co).dot(no) < 0 and (center - prev_co).dot(no) >= 0: + if (center - co).dot(test_normal) < 0 and (center - prev_co).dot(test_normal) >= 0: face.material_index = material_index has_layer_styles = True @@ -1097,13 +1204,35 @@ class Loader(bonsai.core.tool.Loader): return mesh @classmethod - def get_extrusion_vector(cls, wall): - if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"): + def get_extrusion_vector(cls, element): + """Get the extrusion direction in WORLD coordinates (accounting for object rotation)""" + if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): for item in ifcopenshell.util.representation.resolve_representation(body).Items: while item.is_a("IfcBooleanResult"): item = item.FirstOperand if item.is_a("IfcExtrudedAreaSolid"): - return Vector(item.ExtrudedDirection.DirectionRatios) + local_direction = Vector(item.ExtrudedDirection.DirectionRatios) + + # Transform to world coordinates using object rotation + obj = tool.Ifc.get_object(element) + if obj: + # Apply object rotation to get actual world direction + world_direction = obj.matrix_world.to_3x3() @ local_direction + return world_direction + + return local_direction + return Vector([0.0, 0.0, 1.0]) + + @classmethod + def get_local_extrusion_vector(cls, element): + """Get the extrusion direction in LOCAL coordinates (from IFC, no object rotation)""" + if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): + for item in ifcopenshell.util.representation.resolve_representation(body).Items: + while item.is_a("IfcBooleanResult"): + item = item.FirstOperand + if item.is_a("IfcExtrudedAreaSolid"): + local_direction = Vector(item.ExtrudedDirection.DirectionRatios) + return local_direction return Vector([0.0, 0.0, 1.0]) @classmethod diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index bfe8d6b020..e7da203a85 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -620,6 +620,71 @@ 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") + if pset: + 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 = { + "UseCustomOffset": props.use_custom_offset, + "CustomOffset": props.custom_offset / unit_scale, + "CustomWallReference": custom_wall_reference if custom_wall_reference else "", + "CustomSlabReference": custom_slab_reference if custom_slab_reference else "", + } + ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties=properties) + + @classmethod + def load_custom_offset_from_pset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: + """Load custom offset settings from BBIM_MaterialLayer pset.""" + 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": + custom_wall_ref = pset.get("CustomWallReference", "") + if custom_wall_ref: + props.custom_wall_reference = custom_wall_ref + elif usage_type == "LAYER3": + custom_slab_ref = pset.get("CustomSlabReference", "") + if custom_slab_ref: + props.custom_slab_reference = custom_slab_ref + class MaterialLayerParameters(TypedDict): """Float values are in project units.""" @@ -652,13 +717,35 @@ class Model(bonsai.core.tool.Model): ) @classmethod - def get_material_layer_custom_offset(cls, element: ifcopenshell.entity_instance, obj) -> MaterialLayerParameters: + 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) - if props.use_custom_offset: + + # 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") + if pset and pset.get("UseCustomOffset", False): + # 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": + custom_offset_reference = pset.get("CustomSlabReference", "MIDDLE") + else: + return None + else: + return None + else: + # Use current props + custom_offset = props.custom_offset / unit_scale if tool.Model.get_usage_type(element) == "LAYER2": custom_offset_reference = props.custom_wall_reference elif tool.Model.get_usage_type(element) == "LAYER3": @@ -666,24 +753,22 @@ class Model(bonsai.core.tool.Model): else: return None - custom_offset = props.custom_offset - 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"}: - layer_offset = custom_offset - (thickness / 2) * unit_scale - if (direction_sense == "POSITIVE" and custom_offset_reference in {"EXTERIOR", "BOTTOM"}) or ( - direction_sense == "NEGATIVE" and custom_offset_reference in {"EXTERIOR", "TOP"} - ): - layer_offset = custom_offset - if direction_sense == "NEGATIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}: - layer_offset = custom_offset + (thickness / 2) * unit_scale - if direction_sense == "NEGATIVE" and custom_offset_reference in {"INTERIOR", "BOTTOM"}: - layer_offset = custom_offset + thickness * unit_scale + direction_sense = layer_params["direction_sense"] - return layer_offset / unit_scale + 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"}: + layer_offset = custom_offset - (thickness / 2) * unit_scale + if (direction_sense == "POSITIVE" and custom_offset_reference in {"EXTERIOR", "BOTTOM"}) or ( + direction_sense == "NEGATIVE" and custom_offset_reference in {"EXTERIOR", "TOP"} + ): + layer_offset = custom_offset + if direction_sense == "NEGATIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}: + layer_offset = custom_offset + (thickness / 2) * unit_scale + if direction_sense == "NEGATIVE" and custom_offset_reference in {"INTERIOR", "BOTTOM"}: + layer_offset = custom_offset + thickness * unit_scale - return None + return layer_offset / unit_scale @classmethod def get_booleans( diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 0bfb4b00c1..11c4bef3c9 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -513,3 +513,66 @@ class Project(bonsai.core.tool.Project): if tmp.exists(): shutil.rmtree(tmp) bpy.ops.bim.save_project(filepath=cls.TEMP_PROJECT_PATH.__str__(), should_save_as=True) + + @classmethod + def get_metadata_document_information(cls) -> Optional[ifcopenshell.entity_instance]: + ifc_file = tool.Ifc.get() + if not ifc_file: + return None + for doc in ifc_file.by_type("IfcDocumentInformation"): + if getattr(doc, "Scope", None) == "BLEND_METADATA": + return doc + return None + + @classmethod + def create_metadata_document_information(cls, metadata_filename: str) -> ifcopenshell.entity_instance: + 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, + }, + ) + 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, + }, + ) + + return doc + + @classmethod + def update_metadata_document_information(cls, metadata_filename: str) -> None: + 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}) + + @classmethod + def remove_metadata_document_information(cls) -> None: + doc = cls.get_metadata_document_information() + if doc: + tool.Ifc.run("document.remove_information", information=doc) diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py index 04ec6c538d..0e6fe06f9e 100644 --- a/src/bonsai/bonsai/tool/snap.py +++ b/src/bonsai/bonsai/tool/snap.py @@ -553,7 +553,11 @@ class Snap(bonsai.core.tool.Snap): def filter_snapping_points_by_type(snapping_points): options = ["Plane", "Axis"] props = tool.Snap.get_snap_props() - for prop in props.__annotations__.keys(): + try: + annotations = props.__annotations__ + except AttributeError: + annotations = type(props).__annotations__ + for prop in annotations.keys(): if getattr(props, prop): options.append(props.rna_type.properties[prop].name) @@ -563,7 +567,11 @@ class Snap(bonsai.core.tool.Snap): def filter_snapping_points_by_group(detected_snaps): options = ["Wireframe", "Axis", "Plane"] props = tool.Snap.get_snap_groups() - for prop in props.__annotations__.keys(): + try: + annotations = props.__annotations__ + except AttributeError: + annotations = type(props).__annotations__ + for prop in annotations.keys(): if getattr(props, prop): options.append(props.rna_type.properties[prop].name) filtered_groups = [group for group in detected_snaps if group["group"] in options] diff --git a/src/bonsai/bonsai/tool/type.py b/src/bonsai/bonsai/tool/type.py index dab53ee14f..6c98e8bb59 100644 --- a/src/bonsai/bonsai/tool/type.py +++ b/src/bonsai/bonsai/tool/type.py @@ -74,7 +74,7 @@ class Type(bonsai.core.tool.Type): def get_model_types(cls) -> list[ifcopenshell.entity_instance]: ifc_file = tool.Ifc.get() types = ifc_file.by_type("IfcElementType") - # exclude IfcSpatialElementType + types += ifc_file.by_type("IfcSpatialElementType") types += ifc_file.by_type("IfcTypeProduct", include_subtypes=False) if not tool.Ifc.get_schema().startswith("IFC4X3"): types += ifc_file.by_type("IfcWindowStyle") diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index c982c43523..f7c9ce300b 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -166,6 +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/docs/guides/development/installation.rst b/src/bonsai/docs/guides/development/installation.rst index e68d90aa00..3446617ab6 100644 --- a/src/bonsai/docs/guides/development/installation.rst +++ b/src/bonsai/docs/guides/development/installation.rst @@ -200,6 +200,7 @@ Packaged installation - **Arch Linux**: `Direct from Git `__. - **Chocolatey on Windows**: `Unstable `__. +- **Fedora Linux**: `IfcOpenShell Copr repository `__. Tips for package managers ------------------------- diff --git a/src/bonsai/scripts/dev_environment_vscode_config.py b/src/bonsai/scripts/dev_environment_vscode_config.py new file mode 100644 index 0000000000..b574ba9bf4 --- /dev/null +++ b/src/bonsai/scripts/dev_environment_vscode_config.py @@ -0,0 +1,24 @@ +import bonsai, json, bpy +from pathlib import Path + +repo_path = Path(bonsai.__file__).resolve().parent +install_path = Path(bonsai.__file__).absolute().parent +assert repo_path != install_path, "Run `dev_environment.py` to setup the development environment symlinks first." + +repo_root = repo_path.parent.parent.parent +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(), + } +) +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/bonsai/test/bim/feature/document.feature b/src/bonsai/test/bim/feature/document.feature index 9b4a34cfb4..35509c2314 100644 --- a/src/bonsai/test/bim/feature/document.feature +++ b/src/bonsai/test/bim/feature/document.feature @@ -6,23 +6,6 @@ Scenario: Load project documents When I press "bim.load_project_documents" Then nothing happens -Scenario: Load document - Given an empty IFC project - And I press "bim.load_project_documents" - And I press "bim.add_information" - And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" - When I press "bim.load_document(document={information})" - Then nothing happens - -Scenario: Load parent document - Given an empty IFC project - And I press "bim.load_project_documents" - And I press "bim.add_information" - And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" - And I press "bim.load_document(document={information})" - When I press "bim.load_parent_document" - Then nothing happens - Scenario: Disable document editing UI Given an empty IFC project And I press "bim.load_project_documents" @@ -57,7 +40,8 @@ Scenario: Add document reference And I press "bim.load_project_documents" And I press "bim.add_information" And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" - And I press "bim.load_document(document={information})" + And I press "bim.load_project_documents" + And I set "scene.BIMDocumentProperties.active_document_index" to "1" When I press "bim.add_document_reference" Then nothing happens @@ -83,16 +67,12 @@ Scenario: Assign document And I press "bim.load_project_documents" And I press "bim.add_information" And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" - And I press "bim.load_document(document={information})" - And I press "bim.add_document_reference" - And the variable "reference" is "{ifc}.by_type('IfcDocumentReference')[-1].id()" 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" - When I press "bim.assign_document(document={reference})" + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" + And I press "bim.assign_class" + When I press "bim.assign_document(document={information})" Then nothing happens Scenario: Unassign document @@ -100,15 +80,11 @@ Scenario: Unassign document And I press "bim.load_project_documents" And I press "bim.add_information" And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" - And I press "bim.load_document(document={information})" - And I press "bim.add_document_reference" - And the variable "reference" is "{ifc}.by_type('IfcDocumentReference')[-1].id()" 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.assign_document(document={reference})" - When I press "bim.unassign_document(document={reference})" - Then nothing happens + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" + And I press "bim.assign_class" + And I press "bim.assign_document(document={information})" + When I press "bim.unassign_document(document={information})" + Then nothing happens \ No newline at end of file diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index fa5ccb36d0..64d7e7cb27 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -96,7 +96,11 @@ class PanelSpy: def __getattr__(self, attr: str) -> PanelSpy | Any: self.spied_attr = attr - if annotation := self.blender_panel.__annotations__.get(attr, None): + try: + annotations = self.blender_panel.__annotations__ + except AttributeError: + annotations = type(self.blender_panel).__annotations__ + if annotation := annotations.get(attr, None): return annotation.keywords.get("default", None) # An operator property if attr == "layout": return self @@ -136,7 +140,11 @@ class PanelSpy: prop_type = props.bl_rna.properties[name].type enum_items = [] if prop_type == "ENUM": - prop_keywords = props.__annotations__[name].keywords + try: + annotations = props.__annotations__ + except AttributeError: + annotations = type(props).__annotations__ + prop_keywords = annotations[name].keywords items = prop_keywords.get("items") if items is not None: if isinstance(items, (list, tuple)): diff --git a/src/bonsai/test/core/test_document.py b/src/bonsai/test/core/test_document.py index 48421c1fe4..5baf3e709f 100644 --- a/src/bonsai/test/core/test_document.py +++ b/src/bonsai/test/core/test_document.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . - import bonsai.core.document as subject from test.core.bootstrap import ifc, document @@ -25,21 +24,10 @@ class TestLoadProjectDocuments: def test_run(self, document): document.clear_document_tree().should_be_called() document.import_project_documents().should_be_called() - document.clear_breadcrumbs().should_be_called() document.enable_editing_ui().should_be_called() subject.load_project_documents(document) -class TestLoadDocument: - def test_run(self, document): - document.clear_document_tree().should_be_called() - document.import_subdocuments("document").should_be_called() - document.import_references("document").should_be_called() - document.disable_editing_document().should_be_called() - document.add_breadcrumb("document").should_be_called() - subject.load_document(document, document="document") - - class TestDisableDocumentEditingUi: def test_run(self, document): document.disable_editing_ui().should_be_called() @@ -47,45 +35,71 @@ class TestDisableDocumentEditingUi: subject.disable_document_editing_ui(document) +class TestDisableObjectDocumentEditingUi: + def test_run(self, document): + document.disable_object_editing_ui().should_be_called() + subject.disable_object_document_editing_ui(document) + + class TestEnableEditingDocument: def test_run(self, document): - document.import_document_attributes("document").should_be_called() document.set_active_document("document").should_be_called() - subject.enable_editing_document(document, document="document") + document.import_document_attributes("document").should_be_called() + subject.enable_editing_document(document, ifc_document="document") class TestDisableEditingDocument: def test_run(self, document): - document.disable_editing_document().should_be_called() + document.clear_active_document().should_be_called() + document.clear_document_attributes().should_be_called() subject.disable_editing_document(document) class TestAddInformation: def test_add_and_reload_tree_at_project_root(self, ifc, document): document.clear_document_tree().should_be_called() - document.get_active_breadcrumb().should_be_called().will_return(None) - ifc.run("document.add_information", parent=None).should_be_called().will_return("information") + document.get_default_parent_for_information().should_be_called().will_return("default_parent") + ifc.run("document.add_information", parent="default_parent").should_be_called().will_return("information") ifc.run("document.add_reference", information="information").should_be_called() + document.is_document_information("default_parent").should_be_called().will_return(True) + document.expand_document("default_parent").should_be_called() document.import_project_documents().should_be_called() + subject.add_information(ifc, document) def test_add_and_reload_tree_at_current_parent(self, ifc, document): document.clear_document_tree().should_be_called() - document.get_active_breadcrumb().should_be_called().will_return("parent") ifc.run("document.add_information", parent="parent").should_be_called().will_return("information") ifc.run("document.add_reference", information="information").should_be_called() - document.import_subdocuments("parent").should_be_called() - document.import_references("parent").should_be_called() - subject.add_information(ifc, document) + document.is_document_information("parent").should_be_called().will_return(True) + document.expand_document("parent").should_be_called() + document.import_project_documents().should_be_called() + + subject.add_information(ifc, document, parent="parent") + + def test_add_without_expanding_if_parent_is_not_information(self, ifc, document): + document.clear_document_tree().should_be_called() + ifc.run("document.add_information", parent="parent").should_be_called().will_return("information") + ifc.run("document.add_reference", information="information").should_be_called() + document.is_document_information("parent").should_be_called().will_return(False) + document.import_project_documents().should_be_called() + + subject.add_information(ifc, document, parent="parent") class TestAddReference: - def test_run(self, ifc, document): - document.get_active_breadcrumb().should_be_called().will_return("parent") + def test_run_with_selected_parent(self, ifc, document): + document.get_selected_document_information().should_be_called().will_return("parent") ifc.run("document.add_reference", information="parent").should_be_called() - document.clear_document_tree().should_be_called() - document.import_subdocuments("parent").should_be_called() - document.import_references("parent").should_be_called() + document.expand_document("parent").should_be_called() + document.import_project_documents().should_be_called() + + subject.add_reference(ifc, document) + + def test_run_without_selected_parent(self, ifc, document): + document.get_selected_document_information().should_be_called().will_return(None) + document.import_project_documents().should_be_called() + subject.add_reference(ifc, document) @@ -96,9 +110,8 @@ class TestEditDocument: ifc.run("document.edit_information", information="document", attributes="attributes").should_be_called() document.disable_editing_document().should_be_called() document.clear_document_tree().should_be_called() - document.get_active_breadcrumb().should_be_called().will_return(None) document.import_project_documents().should_be_called() - subject.edit_document(ifc, document, document="document") + subject.edit_document(ifc, document, ifc_document="document") def test_edit_reference(self, ifc, document): document.export_document_attributes().should_be_called().will_return("attributes") @@ -106,10 +119,8 @@ class TestEditDocument: ifc.run("document.edit_reference", reference="document", attributes="attributes").should_be_called() document.disable_editing_document().should_be_called() document.clear_document_tree().should_be_called() - document.get_active_breadcrumb().should_be_called().will_return("parent") - document.import_subdocuments("parent").should_be_called() - document.import_references("parent").should_be_called() - subject.edit_document(ifc, document, document="document") + document.import_project_documents().should_be_called() + subject.edit_document(ifc, document, ifc_document="document") class TestRemoveDocument: @@ -117,27 +128,24 @@ class TestRemoveDocument: document.clear_document_tree().should_be_called() document.is_document_information("document").should_be_called().will_return(True) ifc.run("document.remove_information", information="document").should_be_called() - document.get_active_breadcrumb().should_be_called().will_return(None) document.import_project_documents().should_be_called() - subject.remove_document(ifc, document, document="document") + subject.remove_document(ifc, document, ifc_document="document") def test_remove_reference(self, ifc, document): document.clear_document_tree().should_be_called() document.is_document_information("document").should_be_called().will_return(False) ifc.run("document.remove_reference", reference="document").should_be_called() - document.get_active_breadcrumb().should_be_called().will_return("parent") - document.import_subdocuments("parent").should_be_called() - document.import_references("parent").should_be_called() - subject.remove_document(ifc, document, document="document") + document.import_project_documents().should_be_called() + subject.remove_document(ifc, document, ifc_document="document") class TestAssignDocument: def test_run(self, ifc): ifc.run("document.assign_document", products=["product"], document="document").should_be_called() - subject.assign_document(ifc, product="product", document="document") + subject.assign_document(ifc, product="product", ifc_document="document") class TestUnassignDocument: def test_run(self, ifc): ifc.run("document.unassign_document", products=["product"], document="document").should_be_called() - subject.unassign_document(ifc, product="product", document="document") + subject.unassign_document(ifc, product="product", ifc_document="document") diff --git a/src/bonsai/test/tool/test_document.py b/src/bonsai/test/tool/test_document.py index 9e3556afd5..64ec2791b0 100644 --- a/src/bonsai/test/tool/test_document.py +++ b/src/bonsai/test/tool/test_document.py @@ -22,6 +22,7 @@ import ifcopenshell.api import ifcopenshell.api.document import bonsai.core.tool import bonsai.tool as tool +import json from test.bim.bootstrap import NewFile from bonsai.tool.document import Document as subject @@ -31,24 +32,6 @@ class TestImplementsTool(NewFile): assert isinstance(subject(), bonsai.core.tool.Document) -class TestAddBreadcrumb(NewFile): - def test_run(self): - ifc = ifcopenshell.file() - tool.Ifc().set(ifc) - document = ifc.createIfcDocumentInformation() - subject.add_breadcrumb(document) - props = tool.Document.get_document_props() - assert props.breadcrumbs[0].name == str(document.id()) - - -class TestClearBreadcrumbs(NewFile): - def test_run(self): - props = tool.Document.get_document_props() - props.breadcrumbs.add() - subject.clear_breadcrumbs() - assert len(props.breadcrumbs) == 0 - - class TestClearDocumentTree(NewFile): def test_run(self): props = tool.Document.get_document_props() @@ -103,15 +86,6 @@ class TestExportDocumentAttributes(NewFile): } -class TestGetActiveBreadcrumb(NewFile): - def test_run(self): - ifc = ifcopenshell.file() - tool.Ifc().set(ifc) - document = ifc.createIfcDocumentInformation() - subject.add_breadcrumb(document) - assert subject.get_active_breadcrumb() == document - - class TestImportDocumentAttributes(NewFile): def test_importing_information(self): ifc = ifcopenshell.file() @@ -166,51 +140,64 @@ class TestImportDocumentAttributes(NewFile): assert props.document_attributes["Description"].string_value == "Description" -class TestImportProjectDocuments(NewFile): +class TestImportProjectDocumentsExpanded(NewFile): def test_run(self): ifc = ifcopenshell.file() tool.Ifc().set(ifc) - ifc.createIfcProject() - document = ifcopenshell.api.document.add_information(ifc) - subject.import_project_documents() - props = tool.Document.get_document_props() - assert len(props.documents) == 1 - assert props.documents[0].ifc_definition_id == document.id() - assert props.documents[0].name == "Unnamed" - assert props.documents[0].identification == "X" - assert props.documents[0].is_information is True - - -class TestImportReferences(NewFile): - def test_run(self): - ifc = ifcopenshell.file() - tool.Ifc().set(ifc) - ifc.createIfcProject() + project = ifc.createIfcProject() document = ifcopenshell.api.document.add_information(ifc) reference = ifcopenshell.api.document.add_reference(ifc, information=document) - subject.import_references(document) + props = tool.Document.get_document_props() - assert len(props.documents) == 1 - assert props.documents[0].ifc_definition_id == reference.id() - assert props.documents[0].name == "Unnamed" - assert props.documents[0].identification == "X" - assert props.documents[0].is_information is False + expanded_docs = [document.id()] # Mark document as expanded + props.json_string = json.dumps(expanded_docs) + + subject.import_project_documents() + props = tool.Document.get_document_props() + + # Should have project root + document + reference = 3 total + assert len(props.documents) == 3 + + assert props.documents[0].ifc_definition_id == -project.id() + assert props.documents[0].document_type == "PROJECT" + + doc_info = next((d for d in props.documents if d.ifc_definition_id == document.id()), None) + assert doc_info is not None + assert doc_info.document_type == "INFORMATION" + + doc_ref = next((d for d in props.documents if d.ifc_definition_id == reference.id()), None) + assert doc_ref is not None + assert doc_ref.location == "" + assert doc_ref.identification == "X" + assert doc_ref.document_type == "REFERENCE" -class TestImportSubdocuments(NewFile): +class TestImportProjectDocumentsCollapsed(NewFile): def test_run(self): ifc = ifcopenshell.file() tool.Ifc().set(ifc) - ifc.createIfcProject() + project = ifc.createIfcProject() document = ifcopenshell.api.document.add_information(ifc) - subdocument = ifcopenshell.api.document.add_information(ifc, parent=document) - subject.import_subdocuments(document) + reference = ifcopenshell.api.document.add_reference(ifc, information=document) + props = tool.Document.get_document_props() - assert len(props.documents) == 1 - assert props.documents[0].ifc_definition_id == subdocument.id() - assert props.documents[0].name == "Unnamed" - assert props.documents[0].identification == "X" - assert props.documents[0].is_information is True + props.json_string = json.dumps([]) # Empty expanded list + + subject.import_project_documents() + props = tool.Document.get_document_props() + + # Should have project root + document = 2 total (reference not imported because parent is collapsed) + assert len(props.documents) == 2 + + assert props.documents[0].ifc_definition_id == -project.id() + assert props.documents[0].document_type == "PROJECT" + + doc_info = next((d for d in props.documents if d.ifc_definition_id == document.id()), None) + assert doc_info is not None + assert doc_info.document_type == "INFORMATION" + + doc_ref = next((d for d in props.documents if d.ifc_definition_id == reference.id()), None) + assert doc_ref is None class TestIsDocumentInformation(NewFile): @@ -222,15 +209,6 @@ class TestIsDocumentInformation(NewFile): assert subject.is_document_information(reference) is False -class TestRemoveLatestBreadcrumb(NewFile): - def test_run(self): - props = tool.Document.get_document_props() - props.breadcrumbs.add() - props.breadcrumbs.add() - subject.remove_latest_breadcrumb() - assert len(props.breadcrumbs) == 1 - - class TestSetActiveDocument(NewFile): def test_run(self): ifc = ifcopenshell.file() diff --git a/src/bsdd/bsdd.py b/src/bsdd/bsdd.py index 17480c87f5..87154855ae 100644 --- a/src/bsdd/bsdd.py +++ b/src/bsdd/bsdd.py @@ -184,7 +184,7 @@ class ClassPropertyContractV1(TypedDict): qudtCodes: NotRequired[list[str]] -class PropertyContractV4(TypedDict): +class PropertyContractV5(TypedDict): dictionaryUri: NotRequired[str] activationDateUtc: str code: str @@ -710,16 +710,14 @@ class Client: params = {k: v for k, v in params.items() if v is not None} return self.get(endpoint, params) - def get_property(self, uri, include_classes=False, language_code="", version: int = 4) -> PropertyContractV4: + def get_property(self, uri, language_code="", version: int = 5) -> PropertyContractV5: """ - Get Property Detail - this API replaces Property + Get Property details. + If you also need the list of classes using the property, then use api/Property/Classes """ - endpoint = f"Property/v{version}" params = { "uri": uri, - "includeClasses": include_classes, "LanguageCode": language_code, } return self.get(endpoint, params) diff --git a/src/ifcgeom/infra_sweep_helper.cpp b/src/ifcgeom/infra_sweep_helper.cpp index b9c9e598d9..58f6d3108a 100644 --- a/src/ifcgeom/infra_sweep_helper.cpp +++ b/src/ifcgeom/infra_sweep_helper.cpp @@ -14,6 +14,27 @@ namespace { } } +namespace { +template > +bool has_intersection(const std::set& A, + const std::set& B) { + auto itA = A.begin(); + auto itB = B.begin(); + + while (itA != A.end() && itB != B.end()) { + if (Cmp()(*itA, *itB)) { + ++itA; + } else if (Cmp()(*itB, *itA)) { + ++itB; + } else { + return true; + } + } + return false; +} + +} + taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, const IfcUtil::IfcBaseClass* inst, const taxonomy::function_item::ptr& fn, std::vector& cross_sections) { std::sort(cross_sections.begin(), cross_sections.end()); @@ -25,7 +46,7 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, // @todo currently only the case is handled where directrix returns a function_item // @todo this "if" statement is not really required because the function returns at the start if the Directrix is not a function_item function if (fn) { - function_item_evaluator evaluator(settings_,fn); + function_item_evaluator evaluator(settings_, fn); double start = std::max(0., cross_sections.front().dist_along); double end = std::min(fn->length(), cross_sections.back().dist_along); @@ -45,6 +66,7 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, // parameter is minimum number of steps num_steps = (size_t)std::ceil(param); } + auto delta_step = curve_length / num_steps; std::vector longitudes; for (auto& x : cross_sections) { longitudes.push_back(x.dist_along); @@ -52,7 +74,7 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, longitudes.push_back(std::numeric_limits::infinity()); auto profile_index = longitudes.begin(); for (size_t i = 0; i <= num_steps; ++i) { - auto dist_along = start + curve_length / num_steps * i; + auto dist_along = start + delta_step * i; while (dist_along > *(profile_index + 1)) { profile_index++; if (profile_index == longitudes.end()) { @@ -60,6 +82,8 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, } } + const bool is_last_placement_of_this_profile = profile_index + 1 >= longitudes.end() ? false : ((start + delta_step * (i+1)) > *(profile_index + 1)); + auto relative_dist_along = (dist_along - *profile_index) / (*(profile_index + 1) - *profile_index); const auto& profile_a = cross_sections[std::distance(longitudes.begin(), profile_index)].section_geometry; const auto& offset_a = cross_sections[std::distance(longitudes.begin(), profile_index)].offset; @@ -143,28 +167,203 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, } else if (rotation_a != rotation_b) { Logger::Error("Direction vectors on cross section placements only supported when used consistently"); } + taxonomy::loop::ptr w1, w2; taxonomy::edge::ptr e1, e2; + taxonomy::point3::ptr p1, p2; + for (auto tmp_ : boost::combine(loops_a, loops_b)) { boost::tie(w1, w2) = tmp_; - if (w1->children.size() != w2->children.size()) { - Logger::Warning("Mismatching number of edges: " + - std::to_string(w1->children.size()) + " vs " + - std::to_string(w2->children.size()), - inst - ); - return nullptr; - } - std::vector points; - for (auto tmp__ : boost::combine(w1->children, w2->children)) { - boost::tie(e1, e2) = tmp__; - auto& p1 = boost::get(e1->start); - auto& p2 = boost::get(e2->start); - auto p3 = (lerp(p1->ccomponents(), p2->ccomponents(), relative_dist_along) + interpolated_offset).eval(); - // auto p4 = (interpolated_rotation * p3).eval(); - points.push_back(taxonomy::make(p3)); + if (w1->closed != w2->closed) { + Logger::Warning("Mismatching closed property on loops", inst); + return nullptr; + } + + if (w1->tags.is_initialized() != w2->tags.is_initialized()) { + Logger::Warning("Mismatching availability tags on loops", inst); + return nullptr; + } + + if (w1->tags) { + // check uniqueness + std::set tags_seen; + for (const auto& t : *w1->tags) { + if (tags_seen.find(t) != tags_seen.end()) { + Logger::Warning("Duplicate tag '" + t + "' on loft profile", inst); + return nullptr; + } + tags_seen.insert(t); + } } + + if (w2->tags) { + // check uniqueness + std::set tags_seen; + for (const auto& t : *w2->tags) { + if (tags_seen.find(t) != tags_seen.end()) { + Logger::Warning("Duplicate tag '" + t + "' on loft profile", inst); + return nullptr; + } + tags_seen.insert(t); + } + } + + std::map tag_to_point_on_w1, tag_to_point_on_w2; + + auto loop_to_points = [](const taxonomy::loop::ptr& loop, const boost::optional>& input_tags) -> std::pair, std::vector>> { + std::vector points; + std::vector> tags; + std::vector::const_iterator tag_it; + + if (!loop->closed.get_value_or(false)) { + points = {boost::get(loop->children[0]->start)}; + if (input_tags) { + tags = {{input_tags->front()}}; + tag_it = ++input_tags->begin(); + } + } + for (auto& e : loop->children) { + const auto& p1_ = boost::get(e->start); + const auto& p2_ = boost::get(e->end); + if (input_tags && p1_->ccomponents() == p2_->ccomponents()) { + tags.back().insert(*tag_it); + ++tag_it; + } else { + points.push_back(p2_); + if (input_tags) { + tags.emplace_back(); + tags.back().insert(*tag_it); + ++tag_it; + } + } + } + if (!input_tags) { + if (loop->closed.get_value_or(false)) { + // close polygon by referencing first point + points.push_back(points.front()); + } + } + return {points, tags}; + }; + + auto combine_tags = [](const std::vector>& tag_sets) -> std::set { + return std::accumulate( + tag_sets.begin(), tag_sets.end(), std::set{}, + [](std::set acc, + const std::set& m) { + acc.insert(m.begin(), m.end()); + return acc; + }); + }; + + auto join_tags = [](const std::set& tag_set) -> std::string { + std::string result; + for (auto it = tag_set.begin(); it != tag_set.end(); ++it) { + if (it != tag_set.begin()) { + result += ", "; + } + result += *it; + } + return result; + }; + + auto [w1_points, w1_tags] = loop_to_points(w1, w1->tags); + auto [w2_points, w2_tags] = loop_to_points(w2, w2->tags); + + if (w1->tags && w2->tags) { + { + auto it = w1_points.begin(); + auto jt = w1_tags.begin(); + while (it != w1_points.end() && jt != w1_tags.end()) { + for (auto& t : *jt) { + tag_to_point_on_w1[t] = *it; + } + ++it; + ++jt; + } + } + + { + auto it = w2_points.begin(); + auto jt = w2_tags.begin(); + while (it != w2_points.end() && jt != w2_tags.end()) { + for (auto& t : *jt) { + tag_to_point_on_w2[t] = *it; + } + ++it; + ++jt; + } + } + + auto w1_tags_combined = combine_tags(w1_tags); + auto w2_tags_combined = combine_tags(w2_tags); + + // For every point (which can have multiple tags in case of 0-width edges) there needs to be a corresponding point on the other profile + + for (auto& p1_tags : w1_tags) { + if (!has_intersection(p1_tags, w2_tags_combined)) { + Logger::Warning("No matching tags found on loft profiles: " + join_tags(p1_tags) + " not in " + join_tags(w2_tags_combined), inst); + return nullptr; + } + } + + for (auto& p2_tags : w2_tags) { + if (!has_intersection(p2_tags, w1_tags_combined)) { + Logger::Warning("No matching tags found on loft profiles: " + join_tags(p2_tags) + " not in " + join_tags(w1_tags_combined), inst); + return nullptr; + } + } + } else { + if (w1->children.size() != w2->children.size()) { + Logger::Warning("Mismatching number of edges: " + + std::to_string(w1->children.size()) + " vs " + + std::to_string(w2->children.size()), + inst); + return nullptr; + } + } + + std::vector points; + + std::vector common_tags_vec; + if (w1->tags) { + std::set common_tags; + for (const auto& t : *w1->tags) { + if (tag_to_point_on_w2.find(t) == tag_to_point_on_w2.end()) { + continue; + } + + const auto& p1_ = tag_to_point_on_w1[t]; + const auto& p2_ = tag_to_point_on_w2[t]; + + auto p3 = (lerp(p1_->ccomponents(), p2_->ccomponents(), relative_dist_along) + interpolated_offset).eval(); + + std::set tags_for_this_point_on_subsequent_profile = {t}; + + if (is_last_placement_of_this_profile) { + for (auto& ts : w2_tags) { + if (ts.find(t) != ts.end()) { + tags_for_this_point_on_subsequent_profile = ts; + } + } + } + + for (auto& x : tags_for_this_point_on_subsequent_profile) { + points.push_back(taxonomy::make(p3)); + common_tags_vec.push_back(x); + } + } + } else { + for (auto tmp__ : boost::combine(w1_points, w2_points)) { + boost::tie(p1, p2) = tmp__; + auto p3 = (lerp(p1->ccomponents(), p2->ccomponents(), relative_dist_along) + interpolated_offset).eval(); + points.push_back(taxonomy::make(p3)); + } + } + + /* + // This is handled in the loop_to_points() function above if (!points.empty()) { if (!w1->closed.get_value_or(true) && !w2->closed.get_value_or(true)) { // open polygon, add last point @@ -178,12 +377,17 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, points.push_back(points.front()); } } + */ auto interpolated_loop = polygon_from_points(points); - interpolated_loop->external = w1->external; if (interpolated->kind() == taxonomy::FACE) { - std::static_pointer_cast(interpolated)->children.push_back(interpolated_loop); + interpolated_loop->external = w1->external; + std::static_pointer_cast(interpolated)->children.push_back(interpolated_loop); } else { + if (w1->tags) { + std::static_pointer_cast(interpolated)->tags = common_tags_vec; + } + std::static_pointer_cast(interpolated)->closed = w1->closed; std::static_pointer_cast(interpolated)->children = interpolated_loop->children; } } diff --git a/src/ifcgeom/kernels/opencascade/loft.cpp b/src/ifcgeom/kernels/opencascade/loft.cpp index 188150b614..60997d24ad 100644 --- a/src/ifcgeom/kernels/opencascade/loft.cpp +++ b/src/ifcgeom/kernels/opencascade/loft.cpp @@ -27,12 +27,34 @@ #include #include #include +#include using namespace ifcopenshell::geometry; using namespace ifcopenshell::geometry::kernels; using namespace IfcGeom; using namespace IfcGeom::util; +// @todo duplicated +namespace { +template > +bool has_intersection(const std::set& A, + const std::set& B) { + auto itA = A.begin(); + auto itB = B.begin(); + + while (itA != A.end() && itB != B.end()) { + if (Cmp()(*itA, *itB)) { + ++itA; + } else if (Cmp()(*itB, *itA)) { + ++itB; + } else { + return true; + } + } + return false; +} +} + bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& result) { if (loft->children.size() < 2) { return false; @@ -110,37 +132,125 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re BRep_Builder BB; BB.MakeCompound(comp); - // @todo this approach is - // potentially incorrect as there is no guarantee that the wires for - // subsequently placed profiles are traversed from an equivalent start vertex. + std::vector shps(loft->children.size()); + std::vector>> all_tags; - for (auto it = loft->children.begin(); it < loft->children.end() - 1; ++it) { + + std::ostringstream oss; + loft->children[0]->print(oss); + loft->children[1]->print(oss); + auto s = oss.str(); + std::wcout << s.c_str() << std::endl; + + // First convert all taxonomy items to TopoDS_Wire/Face + for (auto it = loft->children.begin(); it < loft->children.end(); ++it) { + auto i = std::distance(loft->children.begin(), it); + if ((*it)->kind() == taxonomy::FACE) { + if (!convert(std::static_pointer_cast((*it)), shps[i])) { + return false; + } + } + if ((*it)->kind() == taxonomy::LOOP) { + + // @todo duplicated with infra_sweep_helper + // I think make_loft() where should just return a shell instead, because + // this faceted lofting does not depend on any functionality in the geometry library + // and the branching with tags needs to be solved twice otherwise + auto loop_to_points = [](const taxonomy::loop::ptr& loop, const boost::optional>& input_tags) -> std::pair, std::vector>> { + std::vector points; + std::vector> tags; + std::vector::const_iterator tag_it; + + if (!loop->closed.get_value_or(false)) { + points = {boost::get(loop->children[0]->start)}; + if (input_tags) { + tags = {{input_tags->front()}}; + tag_it = ++input_tags->begin(); + } + } + for (auto& e : loop->children) { + const auto& p1 = boost::get(e->start); + const auto& p2 = boost::get(e->end); + if (input_tags && p1->ccomponents() == p2->ccomponents()) { + tags.back().insert(*tag_it); + ++tag_it; + } else { + points.push_back(p2); + if (input_tags) { + tags.emplace_back(); + tags.back().insert(*tag_it); + ++tag_it; + } + } + } + if (!input_tags) { + if (loop->closed.get_value_or(false)) { + // close polygon by referencing first point + points.push_back(points.front()); + } + } + return {points, tags}; + }; + + auto lp = std::static_pointer_cast(*it); + TopoDS_Wire w; + + if (lp->tags) { + auto [points, tags] = loop_to_points(lp, lp->tags); + BRepBuilderAPI_MakePolygon mp; + for (auto& p : points) { + const auto& xyz = p->ccomponents(); + mp.Add(gp_Pnt(xyz(0), xyz(1), xyz(2))); + } + w = mp.Wire(); + + if (lp->matrix && !lp->matrix->is_identity()) { + const auto& m = lp->matrix->ccomponents(); + gp_Trsf tr; + tr.SetValues( + m(0, 0), m(0, 1), m(0, 2), m(0, 3), m(1, 0), m(1, 1), m(1, 2), m(1, 3), m(2, 0), m(2, 1), m(2, 2), m(2, 3)); + w = TopoDS::Wire(BRepBuilderAPI_Transform(w, tr).Shape()); + } + + all_tags.push_back(tags); + } else { + if (!convert(std::static_pointer_cast((*it)), w)) { + return false; + } + } + + shps[i] = w; + } + if (shps[i].ShapeType() != TopAbs_FACE && shps[i].ShapeType() != TopAbs_WIRE) { + return false; + } + } + + /* + // With --dimensionality CURVES_SURFACES_AND_SOLIDS this will give the interpolated profiles as line geometry + { + for (auto& f : shps) { + BB.Add(comp, f); + } + } + result = comp; + return true; + */ + + // @todo this approach is + // potentially incorrect as there is no guarantee that the wires for + // subsequently placed profiles are traversed from an equivalent start vertex. + for (auto it = shps.begin(); it < shps.end() - 1; ++it) { + auto ii = std::distance(shps.begin(), it); auto jt = it + 1; - std::array fa = { *it, *jt }; - std::array shps; + std::array::const_iterator, 2> fa = { it, jt }; std::vector> ws; ws.emplace_back(); for (int i = 0; i < 2; ++i) { - if (fa[i]->kind() == taxonomy::FACE) { - if (!convert(std::static_pointer_cast(fa[i]), shps[i])) { - return false; - } - } - if (fa[i]->kind() == taxonomy::LOOP) { - TopoDS_Wire w; - if (!convert(std::static_pointer_cast(fa[i]), w)) { - return false; - } - shps[i] = w; - } - if (shps[i].ShapeType() != TopAbs_FACE && shps[i].ShapeType() != TopAbs_WIRE) { - return false; - } - - if (shps[i].ShapeType() == TopAbs_FACE) { - ws[0][i] = BRepTools::OuterWire(TopoDS::Face(shps[i])); + if (fa[i]->ShapeType() == TopAbs_FACE) { + ws[0][i] = BRepTools::OuterWire(TopoDS::Face(*fa[i])); size_t j = 1; - for (TopExp_Explorer exp(shps[i], TopAbs_WIRE); exp.More(); exp.Next()) { + for (TopExp_Explorer exp(*fa[i], TopAbs_WIRE); exp.More(); exp.Next()) { if (exp.Current() != ws[0][i]) { while (ws.size() <= j) { ws.emplace_back(); @@ -149,22 +259,110 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re } } } else { - ws[0][i] = TopoDS::Wire(shps[i]); + ws[0][i] = TopoDS::Wire(*fa[i]); } } - if (shps[0].ShapeType() == TopAbs_FACE) { + if (it->ShapeType() == TopAbs_FACE) { // When processing a sectioned *surface* there are no // begin and end caps that need to be added. - if (it == loft->children.begin()) { + if (it == shps.begin()) { // faces.Append(shps[0]); BB.Add(comp, shps[0]); } - if (jt == loft->children.end() - 1) { + if (jt == shps.end() - 1) { // faces.Append(shps[1]); BB.Add(comp, shps[1]); } } + if (!all_tags.empty()) { + // only open profiles have tags for now, so there is only one wire, no inner wires + const auto& wp = ws[0]; + std::array, 2> profile_points; + std::array>>::const_iterator, 2> tag_pairs = { + all_tags.begin() + std::distance(shps.begin(), it), + all_tags.begin() + std::distance(shps.begin(), jt)}; + + for (size_t i = 0; i < 2; ++i) { + TopTools_IndexedDataMapOfShapeListOfShape ancestors; + const auto& wire = wp[i]; + auto& result = profile_points[i]; + + TopExp::MapShapesAndAncestors( + wire, + TopAbs_VERTEX, + TopAbs_EDGE, + ancestors); + + TopoDS_Vertex v0, vn, previous; + TopExp::Vertices(wire, v0, vn); + + TopoDS_Vertex curr = v0; + result.push_back(BRep_Tool::Pnt(curr)); + + while (true) { + if (curr.IsSame(vn)) { + break; + } + + const TopTools_ListOfShape& incidentEdges = ancestors.FindFromKey(curr); + + for (TopTools_ListIteratorOfListOfShape it(incidentEdges); it.More(); it.Next()) { + const TopoDS_Edge& e = TopoDS::Edge(it.Value()); + + TopoDS_Vertex ev0, ev1; + TopExp::Vertices(e, ev0, ev1); + + TopoDS_Vertex other_on_edge = curr.IsSame(ev0) ? ev1 : ev0; + if (other_on_edge.IsSame(previous)) { + continue; + } else { + previous = curr; + curr = other_on_edge; + result.push_back(BRep_Tool::Pnt(curr)); + break; + } + } + } + } + + auto a = profile_points[0].begin(); + auto b = profile_points[1].begin(); + auto c = tag_pairs[0]->begin(); + auto d = tag_pairs[1]->begin(); + + if (!has_intersection(*c, *d)) { + throw std::runtime_error("Starting vertices do not have corresponding tags"); + } + + auto emit_triangle = [&](const gp_Pnt& p1, const gp_Pnt& p2, const gp_Pnt& p3) { + BB.Add(comp, BRepBuilderAPI_MakeFace(BRepBuilderAPI_MakePolygon(p1, p2, p3, true).Wire()).Face()); + }; + + while (c != (tag_pairs[0]->end() - 1) && d != (tag_pairs[0]->end() - 1)) { + if (c != (tag_pairs[0]->end() - 1) && has_intersection(*(c + 1), *d)) { + emit_triangle(*a, *(a + 1), *b); + ++a; + ++c; + } else if (d != (tag_pairs[1]->end() - 1) && has_intersection(*c, *(d + 1))) { + emit_triangle(*a, *(b + 1), *b); + ++b; + ++d; + } else if (c != (tag_pairs[0]->end() - 1) && d != (tag_pairs[1]->end() - 1) && has_intersection(*(c + 1), *(d + 1))) { + emit_triangle(*a, *(a + 1), *b); + emit_triangle(*(a + 1), *(b + 1), *b); + ++a; + ++b; + ++c; + ++d; + } else { + throw std::runtime_error("Unable to construct surface"); + } + } + + continue; + } + for (auto& wp : ws) { BRepTools_WireExplorer a(wp[0]); BRepTools_WireExplorer b(wp[1]); diff --git a/src/ifcgeom/mapping/IfcOpenCrossProfileDef.cpp b/src/ifcgeom/mapping/IfcOpenCrossProfileDef.cpp index 334be1324a..7f3c4f43c1 100644 --- a/src/ifcgeom/mapping/IfcOpenCrossProfileDef.cpp +++ b/src/ifcgeom/mapping/IfcOpenCrossProfileDef.cpp @@ -50,7 +50,6 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcOpenCrossProfileDef* inst) { if (tags.has_value() && !tags.get().empty()) { tag = tags.get()[0]; } - // start->tag = tag; auto widths = inst->Widths(); auto angles = inst->Slopes(); // these are actually angles, but the attribute is called Slopes @@ -79,16 +78,13 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcOpenCrossProfileDef* inst) { tag = tags.get()[i+1]; } - // points.push_back(taxonomy::make(x, y, z, tag)); points.push_back(taxonomy::make(x, y, z)); } auto mapped = polygon_from_points(points); - if (mapped->kind() == taxonomy::LOOP) { - auto r = taxonomy::loop::ptr((taxonomy::loop*)mapped->clone_()); - r->closed = false; - return r; - } + mapped->closed = false; + mapped->tags = tags; + return mapped; } diff --git a/src/ifcgeom/taxonomy.cpp b/src/ifcgeom/taxonomy.cpp index e03c1010a3..7d509db8cb 100644 --- a/src/ifcgeom/taxonomy.cpp +++ b/src/ifcgeom/taxonomy.cpp @@ -822,11 +822,11 @@ namespace { boost::optional ifcopenshell::geometry::taxonomy::loop_to_function_item_upgrade_impl(ptr item) { - boost::optional fi_; + boost::optional function_item_; auto loop_ = dcast(item); if (loop_) { - if (loop_->fi.is_initialized()) { - fi_ = loop_->fi; + if (loop_->function_item.is_initialized()) { + function_item_ = loop_->function_item; } else { // piecewise_function is a specialization of function_item - callers don't need to know this detail piecewise_function::spans_t spans; @@ -880,9 +880,9 @@ boost::optional ifcopenshell::geometry::taxonomy::loop_to_fu return boost::none; } } - fi_ = make(0.0,spans); - loop_->fi = fi_; + function_item_ = make(0.0, spans); + loop_->function_item = function_item_; } } - return fi_; + return function_item_; } diff --git a/src/ifcgeom/taxonomy.h b/src/ifcgeom/taxonomy.h index d3ec5c5e17..ab8a222aee 100644 --- a/src/ifcgeom/taxonomy.h +++ b/src/ifcgeom/taxonomy.h @@ -928,7 +928,8 @@ typedef item const* ptr; DECLARE_PTR(loop) boost::optional external, closed; - boost::optional fi; + boost::optional function_item; + boost::optional> tags; bool is_polyhedron() const { for (auto& e : children) { 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 284f6e7c30..d3a6daaad6 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py @@ -100,10 +100,15 @@ class Usecase: size = self.convert_si_to_unit(1) points = ((0.0, 0.0), (size, 0.0), (size, size), (0.0, size), (0.0, 0.0)) if self.polyline: - points = [ - (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle)))) - for p in self.polyline - ] + # Only scale polyline if we have actual slope + if self.x_angle and abs(self.x_angle) > 1e-6: + points = [ + (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle)))) + for p in self.polyline + ] + else: + points = [(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1])) for p in self.polyline] + if self.file.schema == "IFC2X3": curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points]) else: @@ -114,21 +119,23 @@ class Usecase: else: direction_ratios = (0.0, 0.0, 1.0) - offset_direction = direction_ratios # offset direction doesn't change if direction_sense is negative extrusion_direction = self.file.createIfcDirection(direction_ratios) - if self.direction_sense == "NEGATIVE": - direction_ratios = tuple(-n for n in direction_ratios) - extrusion_direction = self.file.createIfcDirection(direction_ratios) - perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(self.x_angle)) - perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(self.x_angle)) + # Calculate depth based on extrusion angle + extrusion_angle = abs(self.x_angle) if self.x_angle else 0 + if extrusion_angle > 1e-6: + perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(extrusion_angle)) + perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(extrusion_angle)) + else: + perpendicular_depth = self.convert_si_to_unit(self.depth) + perpendicular_offset = self.convert_si_to_unit(self.offset) + position = None - # default position for IFC2X3 where .Position is not optional if self.file.schema == "IFC2X3" or self.offset != 0: position_vector = ( - offset_direction[0] * perpendicular_offset, - offset_direction[1] * perpendicular_offset, - offset_direction[2] * perpendicular_offset, + direction_ratios[0] * perpendicular_offset, + direction_ratios[1] * perpendicular_offset, + direction_ratios[2] * perpendicular_offset, ) position = self.file.createIfcAxis2Placement3D( self.file.createIfcCartesianPoint(position_vector), diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py index 4c108dc7df..12a033429d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py @@ -85,7 +85,6 @@ class Usecase: def create_item(self) -> ifcopenshell.entity_instance: length = self.convert_si_to_unit(self.settings["length"]) thickness = self.convert_si_to_unit(self.settings["thickness"]) - thickness *= 1 / cos(self.settings["x_angle"]) if self.settings["direction_sense"] == "NEGATIVE": thickness *= -1 points = ( @@ -113,7 +112,7 @@ class Usecase: self.file.createIfcDirection((1.0, 0.0, 0.0)), ), extrusion_direction, - self.convert_si_to_unit(self.settings["height"]) * abs(1 / cos(self.settings["x_angle"])), + self.convert_si_to_unit(self.settings["height"]), ) if self.settings["booleans"]: extrusion = self.apply_booleans(extrusion) diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py index c3f55d6795..893b32cc02 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py @@ -243,6 +243,59 @@ class Usecase: except RuntimeError: return None + def material_sets_are_equal(self, set1: ifcopenshell.entity_instance, set2: ifcopenshell.entity_instance) -> bool: + """Check if two material sets are structurally equivalent.""" + if set1.is_a() != set2.is_a(): + return False + + ifc_class = set1.is_a() + + if ifc_class == "IfcMaterialLayerSet": + layers1 = set1.MaterialLayers or [] + layers2 = set2.MaterialLayers or [] + if len(layers1) != len(layers2): + return False + for l1, l2 in zip(layers1, layers2): + if (l1.Material is None) != (l2.Material is None): + return False + if l1.Material and l1.Material.Name != l2.Material.Name: + return False + if l1.LayerThickness != l2.LayerThickness: + return False + + elif ifc_class == "IfcMaterialConstituentSet": + constituents1 = set1.MaterialConstituents or [] + constituents2 = set2.MaterialConstituents or [] + if len(constituents1) != len(constituents2): + return False + for c1, c2 in zip(constituents1, constituents2): + if (c1.Material is None) != (c2.Material is None): + return False + if c1.Material and c1.Material.Name != c2.Material.Name: + return False + if c1.Name != c2.Name: + return False + + elif ifc_class == "IfcMaterialProfileSet": + profiles1 = set1.MaterialProfiles or [] + profiles2 = set2.MaterialProfiles or [] + if len(profiles1) != len(profiles2): + return False + for p1, p2 in zip(profiles1, profiles2): + if (p1.Material is None) != (p2.Material is None): + return False + if p1.Material and p1.Material.Name != p2.Material.Name: + return False + if (p1.Profile is None) != (p2.Profile is None): + return False + if p1.Profile: + profile_name1 = getattr(p1.Profile, "ProfileName", None) + profile_name2 = getattr(p2.Profile, "ProfileName", None) + if profile_name1 != profile_name2: + return False + + return True + def get_existing_element(self, element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: """Get existing element for a library element. @@ -264,13 +317,17 @@ class Usecase: name = element.Name return next((e for e in self.file.by_type("IfcMaterial") if e.Name == name), None) - elif element in MATERIAL_SETS: + elif element.is_a() in MATERIAL_SETS: ifc_class = element.is_a() name_attr = "LayerSetName" if ifc_class == "IfcMaterialLayerSet" else "Name" material_set_name = getattr(element, name_attr) if material_set_name is None: return - return next((e for e in self.file.by_type(ifc_class) if getattr(e, name_attr) == material_set_name), None) + for candidate in self.file.by_type(ifc_class): + if getattr(candidate, name_attr) == material_set_name: + if self.material_sets_are_equal(element, candidate): + return candidate + return None elif element.is_a("IfcProfileDef"): profile_name = element.ProfileName @@ -665,12 +722,11 @@ class Usecase: name_attr = "LayerSetName" if ifc_class == "IfcMaterialLayerSet" else "Name" material_set_name = getattr(element, name_attr) if material_set_name is not None: - existing_material_set = next( - (e for e in ifc_file.by_type(ifc_class) if getattr(e, name_attr) == material_set_name), None - ) - if existing_material_set is not None: - reuse_identities[element_identity] = existing_material_set - return existing_material_set + for candidate in ifc_file.by_type(ifc_class): + if getattr(candidate, name_attr) == material_set_name: + if self.material_sets_are_equal(element, candidate): + reuse_identities[element_identity] = candidate + return candidate elif element.is_a("IfcPresentationStyle"): style_name = element.Name 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