From 691815fd414ad9b3e1cfd1380f6efc1bce43f7dc Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 4 Sep 2024 16:54:24 +0500 Subject: [PATCH] Tools for handling shared psets #5291 In IFC it's possible for a property set to be assigned to multiple elements and which may lead to confusing behaviour when you edit a pset on one element and other element seems to get edited too. Which makes it worse is that that it is possible that some software is might be doing this unintentionally when exporting IFC (as some sort of optimization as storing 1 is more optimal than n copies of it). So now there are some tools in Bonsai and in IfcOpenShell to handle the shared psest: 1) Indication that property is shared - https://imgur.com/a/9dd3jST (similar to how Blender indicates ID data-block users). You can click on it to "unshare" the pset - a new copy for the pset will be created and it's going to be linked only to the active object. 2) api pset.unshare_pset method that does the same. And util.element.get_elements_using_pset method that encapsulates schema differences and different approaches for occurrences/types. 3) ifcpatch recipe 'UnsharePsets' that's making all property sets in the IFC file to have just 1 element that's using them. You can limit the affected elements by providing query. ifcpatch recipe is also available in Bonsai - https://i.imgur.com/aOCx7HI.png --- src/bonsai/bonsai/bim/module/pset/__init__.py | 1 + src/bonsai/bonsai/bim/module/pset/data.py | 6 +- src/bonsai/bonsai/bim/module/pset/operator.py | 31 ++++++ src/bonsai/bonsai/bim/module/pset/ui.py | 12 ++- .../ifcopenshell/api/pset/__init__.py | 2 + .../ifcopenshell/api/pset/unshare_pset.py | 94 +++++++++++++++++++ .../ifcopenshell/util/element.py | 12 +++ .../test/api/pset/test_unshare_pset.py | 74 +++++++++++++++ src/ifcpatch/ifcpatch/recipes/UnsharePsets.py | 85 +++++++++++++++++ src/ifcpatch/test/test_UnsharePsets.py | 94 +++++++++++++++++++ 10 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 src/ifcopenshell-python/ifcopenshell/api/pset/unshare_pset.py create mode 100644 src/ifcopenshell-python/test/api/pset/test_unshare_pset.py create mode 100644 src/ifcpatch/ifcpatch/recipes/UnsharePsets.py create mode 100644 src/ifcpatch/test/test_UnsharePsets.py 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