diff --git a/src/bonsai/bonsai/bim/module/pset/__init__.py b/src/bonsai/bonsai/bim/module/pset/__init__.py index 1bea76dc93..14706e75be 100644 --- a/src/bonsai/bonsai/bim/module/pset/__init__.py +++ b/src/bonsai/bonsai/bim/module/pset/__init__.py @@ -29,6 +29,7 @@ classes = ( operator.EnablePsetEditing, operator.RemovePset, operator.TogglePsetExpansion, + operator.UnsharePset, operator.BIM_OT_add_property_to_edit, operator.BIM_OT_remove_property_to_edit, operator.BIM_OT_clear_list, diff --git a/src/bonsai/bonsai/bim/module/pset/data.py b/src/bonsai/bonsai/bim/module/pset/data.py index c0a19f7bf0..58cecfa83a 100644 --- a/src/bonsai/bonsai/bim/module/pset/data.py +++ b/src/bonsai/bonsai/bim/module/pset/data.py @@ -46,18 +46,22 @@ def refresh(): class Data: @classmethod - def psetqtos(cls, element, psets_only=False, qtos_only=False): + def psetqtos(cls, element: ifcopenshell.entity_instance, psets_only: bool = False, qtos_only: bool = False): + ifc_file = tool.Ifc.get() results = [] psetqtos = ifcopenshell.util.element.get_psets( element, psets_only=psets_only, qtos_only=qtos_only, should_inherit=False ) for name, data in sorted(psetqtos.items()): + pset = ifc_file.by_id(data["id"]) + pset_uses = ifcopenshell.util.element.get_elements_using_pset(pset) results.append( { "id": data["id"], "Name": name, "is_expanded": is_expanded.get(data["id"], True), "Properties": [{"Name": k, "NominalValue": v} for k, v in sorted(data.items()) if k != "id"], + "shared_pset_uses": len(pset_uses), } ) return sorted(results, key=lambda v: v["Name"]) diff --git a/src/bonsai/bonsai/bim/module/pset/operator.py b/src/bonsai/bonsai/bim/module/pset/operator.py index e417de2063..0de52bafa3 100644 --- a/src/bonsai/bonsai/bim/module/pset/operator.py +++ b/src/bonsai/bonsai/bim/module/pset/operator.py @@ -19,6 +19,7 @@ import bpy import json import ifcopenshell.api +import ifcopenshell.api.pset import ifcopenshell.util.attribute import ifcopenshell.util.element import ifcopenshell.util.pset @@ -190,6 +191,36 @@ class AddPset(bpy.types.Operator, tool.Ifc.Operator): core.add_pset(tool.Ifc, tool.Pset, tool.Blender, obj_name=self.obj, obj_type=self.obj_type) +class UnsharePset(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.unshare_pset" + bl_label = "Unshare Pset" + bl_description = ( + "Click to copy a pset as linked only to the active object.\n" + "Otherwise changing a pset shared by multiple elements " + "will change it's properties for all the elements it's linked to, not just for the active object" + ) + bl_options = {"REGISTER", "UNDO"} + description_: bpy.props.StringProperty(name="Custom Tooltip Description") + pset_id: bpy.props.IntProperty() + obj: bpy.props.StringProperty() + obj_type: bpy.props.StringProperty() + + @classmethod + def description(cls, context, properties): + if not properties.description_: + return cls.bl_description + return f"{properties.description_}{cls.bl_description}" + + def _execute(self, context): + # TODO: move to core + ifc_file = tool.Ifc.get() + pset = ifc_file.by_id(self.pset_id) + element_id = tool.Blender.get_obj_ifc_definition_id(self.obj, self.obj_type) + assert element_id + element = ifc_file.by_id(element_id) + ifcopenshell.api.pset.unshare_pset(ifc_file, [element], pset) + + class AddQto(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_qto" bl_label = "Add Qto" diff --git a/src/bonsai/bonsai/bim/module/pset/ui.py b/src/bonsai/bonsai/bim/module/pset/ui.py index 021f8563af..fd01a3b6af 100644 --- a/src/bonsai/bonsai/bim/module/pset/ui.py +++ b/src/bonsai/bonsai/bim/module/pset/ui.py @@ -120,7 +120,17 @@ def draw_psetqto_ui( op.obj = obj_name op.obj_type = obj_type elif not props.active_pset_id: - row.label(text=pset["Name"], icon="COPY_ID") + row.label(text=f'{pset["Name"]}', icon="COPY_ID") + + if (shared := pset["shared_pset_uses"]) > 1: + unshare_pset_row = row.row(align=True) + unshare_pset_row.alignment = "RIGHT" + op = unshare_pset_row.operator("bim.unshare_pset", text=str(shared)) + op.description_ = f"Pset is reused by {shared} elements.\n\n" + op.pset_id = pset_id + op.obj = obj_name + op.obj_type = obj_type + op = row.operator("bim.enable_pset_editing", icon="GREASEPENCIL", text="") op.pset_id = pset_id op.obj = obj_name diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/pset/__init__.py index dd900a473a..73506f83c4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/__init__.py @@ -29,6 +29,7 @@ from .add_qto import add_qto from .edit_pset import edit_pset from .edit_qto import edit_qto from .remove_pset import remove_pset +from .unshare_pset import unshare_pset wrap_usecases(__path__, __name__) @@ -38,4 +39,5 @@ __all__ = [ "edit_pset", "edit_qto", "remove_pset", + "unshare_pset", ] diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/unshare_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/unshare_pset.py new file mode 100644 index 0000000000..a788b0ec61 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/unshare_pset.py @@ -0,0 +1,94 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell +import ifcopenshell.api.owner +import ifcopenshell.guid +import ifcopenshell.util.element + + +def unshare_pset( + file: ifcopenshell.file, + products: list[ifcopenshell.entity_instance], + pset: ifcopenshell.entity_instance, +) -> list[ifcopenshell.entity_instance]: + """Copy a shared pset as linked only to the provided elements. + + Note that method will create a copy of the pset for each element provided. + + :param products: Elements (or element types) to link the pset to. + :param pset: Shared property set. + :return: List of copied property sets. + """ + is_ifc2x3 = file.schema == "IFC2X3" + products_occurrences: set[ifcopenshell.entity_instance] = set() + products_types: set[ifcopenshell.entity_instance] = set() + for product in products: + if product.is_a("IfcTypeProduct"): + products_types.add(product) + else: + products_occurrences.add(product) + + # Check occurrences using pset. + rels = pset.PropertyDefinitionOf if is_ifc2x3 else pset.DefinesOccurrence + for rel in rels: + objs = set(rel.RelatedObjects) + if not any(p in objs for p in products_occurrences): + continue + objs.difference_update(products_occurrences) + if objs: + rel.RelatedObjects = list(objs) + else: + history = rel.OwnerHistory + file.remove(rel) + if history: + ifcopenshell.util.element.remove_deep2(file, history) + + # Check types using pset. + for product in products_types: + if not (psets := product.HasPropertySets): + continue + psets: list[ifcopenshell.entity_instance] = list(psets) + psets.remove(pset) + product.HasPropertySets = psets + + def assign_pset(product: ifcopenshell.entity_instance, pset: ifcopenshell.entity_instance) -> None: + if product.is_a("IfcTypeProduct"): + psets = list(product.HasPropertySets or []) + if not psets: + psets = [] + product.HasPropertySets = psets + [pset] + return + + file.create_entity( + "IfcRelDefinesByProperties", + **{ + "GlobalId": ifcopenshell.guid.new(), + "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), + "RelatedObjects": [product], + "RelatingPropertyDefinition": pset_copy, + }, + ) + + pset_copies: list[ifcopenshell.entity_instance] = [] + for product in products: + pset_copy = ifcopenshell.util.element.copy_deep(file, pset) + pset_copies.append(pset_copy) + assign_pset(product, pset_copy) + + return pset_copies diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index bb29b476a5..40ba257ae8 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -422,6 +422,18 @@ def get_properties( return results +def get_elements_using_pset(pset: ifcopenshell.entity_instance) -> set[ifcopenshell.entity_instance]: + """Retrieve the elements (or element types) that are using the provided property set.""" + is_ifc2x3 = pset.file.schema == "IFC2X3" + elements = set() + rels = pset.PropertyDefinitionOf if is_ifc2x3 else pset.DefinesOccurrence + for rel in rels: + elements.update(rel.RelatedObjects) + for element_type in pset.DefinesType: + elements.add(element_type) + return elements + + def get_predefined_type(element: ifcopenshell.entity_instance) -> str: """Retrieves the PrefefinedType attribute of an element. diff --git a/src/ifcopenshell-python/test/api/pset/test_unshare_pset.py b/src/ifcopenshell-python/test/api/pset/test_unshare_pset.py new file mode 100644 index 0000000000..1b752af1aa --- /dev/null +++ b/src/ifcopenshell-python/test/api/pset/test_unshare_pset.py @@ -0,0 +1,74 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import operator +import test.bootstrap +import ifcopenshell.api.pset +import ifcopenshell.api.root +import ifcopenshell.util.element +import ifcopenshell.guid + + +class TestUnsharePset(test.bootstrap.IFC4): + def test_unshare_pset_for_occurrence(self): + elements = [self.file.create_entity("IfcWall") for _ in range(3)] + + pset = ifcopenshell.api.pset.add_pset(self.file, elements[0], "Foo") + rel = self.file.by_type("IfcRelDefinesByProperties")[0] + rel.RelatedObjects = elements + + new_psets = ifcopenshell.api.pset.unshare_pset(self.file, elements[:2], pset) + assert isinstance(new_psets, list) + assert len(new_psets) == 2 + + assert len(psets := self.file.by_type("IfcPropertySet")) == 3 + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 3 + + used_elements = set() + for pset in psets: + pset_elements = ifcopenshell.util.element.get_elements_using_pset(pset) + assert len(pset_elements) == 1 + used_elements.update(pset_elements) + + assert used_elements == set(elements) + + def test_unshare_pset_for_type(self): + elements = [self.file.create_entity("IfcWallType") for _ in range(3)] + + pset = ifcopenshell.api.pset.add_pset(self.file, elements[0], "Foo") + elements[1].HasPropertySets = (pset,) + elements[2].HasPropertySets = (pset,) + + new_psets = ifcopenshell.api.pset.unshare_pset(self.file, elements[:2], pset) + assert isinstance(new_psets, list) + assert len(new_psets) == 2 + + assert len(psets := self.file.by_type("IfcPropertySet")) == 3 + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 0 + + used_psets: set[ifcopenshell.entity_instance] = set() + for element in elements: + element_psets = element.HasPropertySets + assert len(element_psets) == 1 + used_psets.add(element_psets[0]) + + assert used_psets == set(psets) + + +class TestUnsharePsetIFC2X3(test.bootstrap.IFC2X3, TestUnsharePset): + pass diff --git a/src/ifcpatch/ifcpatch/recipes/UnsharePsets.py b/src/ifcpatch/ifcpatch/recipes/UnsharePsets.py new file mode 100644 index 0000000000..9ea882d2a1 --- /dev/null +++ b/src/ifcpatch/ifcpatch/recipes/UnsharePsets.py @@ -0,0 +1,85 @@ +# IfcPatch - IFC patching utiliy +# Copyright (C) 2020, 2021, 2022 Dion Moult +# +# This file is part of IfcPatch. +# +# IfcPatch is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcPatch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcPatch. If not, see . + +import ifcopenshell +import ifcopenshell.api.pset +import ifcopenshell.guid +import ifcopenshell.util.element +import ifcopenshell.util.selector +from typing import Union +from logging import Logger + + +class Patcher: + def __init__(self, src: str, file: ifcopenshell.file, logger: Logger, query: str = ""): + """Create independent copies for shared psets in IFC file. + + In IFC it's possible that same property set is shared by multiple elements, + so editing it's properties will automatically change their values for all those elements. + + Sometimes it's intended but sometimes it's not and it's just the way some other + software exports IFC (e.g. there is a known case when Tekla exports shared psets for all the occurrences). + While it is more optimized way to store data, it may lead to unexpected results when editing properties. + + This recipe creates independent copies of all shared psets (may be limited by the query) + and assigns them to the elements, so they can be edited without affecting any other elements. + + :param query: A query to select the subset of IFC elements, optional. + If not provided, patch will be applied to all shared property sets in the model. + + Example: + + .. code:: python + + # Unshare all psets in the IFC file. + ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "UnsharePsets"}) + + # Unshare psets on all IfcWalls. + ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "UnsharePsets", "arguments": ["IfcWall"]}) + """ + self.src = src + self.file = file + self.logger = logger + self.query = query + + def patch(self): + filtered_elements: set[ifcopenshell.entity_instance] = set() + if self.query: + filtered_elements = ifcopenshell.util.selector.filter_elements(self.file, self.query) + + all_psets = self.file.by_type("IfcPropertySetDefinition") + psets: dict[ifcopenshell.entity_instance, set[ifcopenshell.entity_instance]] = {} + for pset in all_psets: + elements = ifcopenshell.util.element.get_elements_using_pset(pset) + # Skip non shared psets. + if len(elements) < 2: + continue + # Skip non selected elements. + if self.query: + if not any(e in filtered_elements for e in elements): + continue + elements = elements.intersection(filtered_elements) + psets[pset] = elements + + new_psets = [] + for pset, elements in psets.items(): + # Let the first element to keep the original property set. + elements = list(elements)[1:] + new_psets.extend(ifcopenshell.api.pset.unshare_pset(self.file, elements, pset)) + + print(f"{len(new_psets)} new psets were created.") diff --git a/src/ifcpatch/test/test_UnsharePsets.py b/src/ifcpatch/test/test_UnsharePsets.py new file mode 100644 index 0000000000..29b4c71425 --- /dev/null +++ b/src/ifcpatch/test/test_UnsharePsets.py @@ -0,0 +1,94 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcpatch +import ifcopenshell +import ifcopenshell.api.pset +import ifcopenshell.geom +import ifcopenshell.util.element +import test.bootstrap + + +class TestUnsharePsets(test.bootstrap.IFC4): + def test_unshare_all_psets(self): + elements = [self.file.create_entity("IfcWall") for _ in range(3)] + + ifcopenshell.api.pset.add_pset(self.file, elements[0], "Foo") + rel = self.file.by_type("IfcRelDefinesByProperties")[0] + rel.RelatedObjects = elements + + ifcpatch.execute({"file": self.file, "recipe": "UnsharePsets"}) + assert len(psets := self.file.by_type("IfcPropertySet")) == 3 + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 3 + + used_elements = set() + for pset in psets: + pset_elements = ifcopenshell.util.element.get_elements_using_pset(pset) + assert len(pset_elements) == 1 + used_elements.update(pset_elements) + + assert used_elements == set(elements) + + def test_unshare_all_psets_include_types(self): + elements = [self.file.create_entity("IfcWallType") for _ in range(3)] + + pset = ifcopenshell.api.pset.add_pset(self.file, elements[0], "Foo") + elements[1].HasPropertySets = (pset,) + elements[2].HasPropertySets = (pset,) + + ifcpatch.execute({"file": self.file, "recipe": "UnsharePsets"}) + assert len(psets := self.file.by_type("IfcPropertySet")) == 3 + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 0 + + used_psets: set[ifcopenshell.entity_instance] = set() + for element in elements: + element_psets = element.HasPropertySets + assert len(element_psets) == 1 + used_psets.add(element_psets[0]) + + assert used_psets == set(psets) + + def test_unshare_psets_for_elements_from_query(self): + shared_pset_elements = [self.file.create_entity("IfcSlab") for _ in range(3)] + shared_pset = ifcopenshell.api.pset.add_pset(self.file, shared_pset_elements[0], "Foo") + rel = self.file.by_type("IfcRelDefinesByProperties")[0] + rel.RelatedObjects = shared_pset_elements + + elements = [self.file.create_entity("IfcWall") for _ in range(3)] + ifcopenshell.api.pset.add_pset(self.file, elements[0], "Foo") + rel = self.file.by_type("IfcRelDefinesByProperties")[1] + rel.RelatedObjects = elements + + ifcpatch.execute({"file": self.file, "recipe": "UnsharePsets", "arguments": ["IfcWall"]}) + assert len(psets := self.file.by_type("IfcPropertySet")) == 4 + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 4 + + psets.remove(shared_pset) + used_elements = set() + for pset in psets: + pset_elements = ifcopenshell.util.element.get_elements_using_pset(pset) + assert len(pset_elements) == 1 + used_elements.update(pset_elements) + + assert used_elements == set(elements) + # Leave shared pset untouched as it's not part of the provided query. + assert ifcopenshell.util.element.get_elements_using_pset(shared_pset) == set(shared_pset_elements) + + +class TestUnsharePsetsIFC2X3(test.bootstrap.IFC2X3, TestUnsharePsets): + pass