From 97db95085aea4fef4a05b88a322d17e10be4c8d8 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 9 Apr 2024 16:41:34 +0500 Subject: [PATCH] nest.assign_object - support batching #4474 --- src/blenderbim/blenderbim/core/nest.py | 4 +- src/blenderbim/test/core/test_nest.py | 2 +- .../ifcopenshell/api/__init__.py | 4 + .../ifcopenshell/api/cost/add_cost_item.py | 2 +- .../ifcopenshell/api/cost/copy_cost_item.py | 4 +- .../ifcopenshell/api/nest/assign_object.py | 88 +++++++++++++------ .../ifcopenshell/api/nest/change_nest.py | 2 +- .../ifcopenshell/api/nest/unassign_object.py | 4 +- .../ifcopenshell/api/resource/add_resource.py | 2 +- .../ifcopenshell/api/sequence/add_task.py | 2 +- .../test/api/nest/__init__.py | 17 ++++ .../test/api/nest/test_assign_object.py | 67 ++++++++++++++ .../test/api/root/test_remove_product.py | 4 +- src/ifcopenshell-python/test/api/test_api.py | 8 ++ src/ifctester/test/test_facet.py | 8 +- 15 files changed, 173 insertions(+), 45 deletions(-) create mode 100644 src/ifcopenshell-python/test/api/nest/__init__.py create mode 100644 src/ifcopenshell-python/test/api/nest/test_assign_object.py diff --git a/src/blenderbim/blenderbim/core/nest.py b/src/blenderbim/blenderbim/core/nest.py index f6e8ae4974..549a207022 100644 --- a/src/blenderbim/blenderbim/core/nest.py +++ b/src/blenderbim/blenderbim/core/nest.py @@ -29,7 +29,9 @@ def assign_object(ifc, nest, collector, relating_obj=None, related_obj=None): if not nest.can_nest(relating_obj, related_obj): return rel = ifc.run( - "nest.assign_object", related_object=ifc.get_entity(related_obj), relating_object=ifc.get_entity(relating_obj) + "nest.assign_object", + related_objects=[ifc.get_entity(related_obj)], + relating_object=ifc.get_entity(relating_obj), ) collector.assign(relating_obj) collector.assign(related_obj) diff --git a/src/blenderbim/test/core/test_nest.py b/src/blenderbim/test/core/test_nest.py index bc4d1c1683..2de6e12b4e 100644 --- a/src/blenderbim/test/core/test_nest.py +++ b/src/blenderbim/test/core/test_nest.py @@ -38,7 +38,7 @@ class TestAssignObject: ifc.get_entity("relating_obj").should_be_called().will_return("relating_object") ifc.get_entity("related_obj").should_be_called().will_return("related_object") ifc.run( - "nest.assign_object", related_object="related_object", relating_object="relating_object" + "nest.assign_object", related_objects=["related_object"], relating_object="relating_object" ).should_be_called().will_return("rel") nest.disable_editing("related_obj").should_be_called() collector.assign("relating_obj").should_be_called() diff --git a/src/ifcopenshell-python/ifcopenshell/api/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/__init__.py index 51f1b0cb1c..6f2ad87abc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/__init__.py @@ -67,11 +67,15 @@ ARGUMENTS_DEPRECATION = { new_argument="products", replace_usecase="spatial.unassign_container", ), + "nest.assign_object": partial( + batching_argument_deprecation, prev_argument="related_object", new_argument="related_objects" + ), } CACHED_USECASE_CLASSES = dict() + def run( usecase_path: str, ifc_file: Optional[ifcopenshell.file] = None, diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_item.py b/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_item.py index 13b4b41258..0674d1def4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_item.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_item.py @@ -68,6 +68,6 @@ class Usecase: ) elif self.settings["cost_item"]: ifcopenshell.api.run( - "nest.assign_object", self.file, related_object=cost_item, relating_object=self.settings["cost_item"] + "nest.assign_object", self.file, related_objects=[cost_item], relating_object=self.settings["cost_item"] ) return cost_item diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_item.py b/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_item.py index 84652284a6..30f3bda555 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_item.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_item.py @@ -82,7 +82,7 @@ class Usecase: rel = ifcopenshell.api.run( "nest.assign_object", self.file, - related_object=cost_item, + related_objects=[cost_item], relating_object=to_element, ) # elif inverse.is_a("IfcRelAssignsToProduct"): @@ -104,4 +104,4 @@ class Usecase: elif isinstance(value, (tuple, list)) and from_element in value: new_value = list(value) new_value.append(to_element) - inverse[i] = new_value \ No newline at end of file + inverse[i] = new_value diff --git a/src/ifcopenshell-python/ifcopenshell/api/nest/assign_object.py b/src/ifcopenshell-python/ifcopenshell/api/nest/assign_object.py index e75b1ecf44..a53193afc5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/nest/assign_object.py +++ b/src/ifcopenshell-python/ifcopenshell/api/nest/assign_object.py @@ -19,11 +19,17 @@ import ifcopenshell import ifcopenshell.api import ifcopenshell.util.element +from typing import Union class Usecase: - def __init__(self, file, related_object=None, relating_object=None): - """Assigns an object as a nested child to a parent host + def __init__( + self, + file: ifcopenshell.file, + related_objects: list[ifcopenshell.entity_instance], + relating_object: ifcopenshell.entity_instance, + ): + """Assigns objects as nested children to a parent host All physical IFC model elements must be part of a hierarchical tree called the "spatial decomposition", where large things are made up of @@ -72,14 +78,15 @@ class Usecase: ifcopenshell.api.cost.add_cost_item or ifcopenshell.api.sequence.add_task. - :param related_object: The child of the nesting relationship, typically - an IfcElement. - :type related_object: ifcopenshell.entity_instance.entity_instance + :param related_objects: The list of children of the nesting relationship, + typically IfcElements. + :type related_objects: list[ifcopenshell.entity_instance.entity_instance] :param relating_object: The host parent of the nesting relationship, typically an IfcElement. :type relating_object: ifcopenshell.entity_instance.entity_instance :return: The IfcRelNests relationship instance - :rtype: ifcopenshell.entity_instance.entity_instance + or `None` if `related_objects` was empty list. + :rtype: Union[ifcopenshell.entity_instance.entity_instance, None] Example: @@ -90,30 +97,50 @@ class Usecase: ifc_class="IfcSanitaryTerminal", predefined_type="SINK") faucet = ifcopenshell.api.run("root.create_entity", model, ifc_class="IfcValve", predefined_type="FAUCET") - ifcopenshell.api.run("nest.assign_object", model, related_object=faucet, relating_object=sink) + ifcopenshell.api.run("nest.assign_object", model, related_objects=[faucet], relating_object=sink) """ self.file = file - self.settings = {"related_object": related_object, "relating_object": relating_object} + self.settings = {"related_objects": related_objects, "relating_object": relating_object} - def execute(self): - nests = None - if self.settings["related_object"].Nests: - nests = self.settings["related_object"].Nests[0] - - is_nested_by = None - for rel in self.settings["relating_object"].IsNestedBy: - if rel.is_a("IfcRelNests"): - is_nested_by = rel - break - - if nests and nests == is_nested_by: + def execute(self) -> Union[ifcopenshell.entity_instance, None]: + if not self.settings["related_objects"]: return - if nests: - related_objects = list(nests.RelatedObjects) - related_objects.remove(self.settings["related_object"]) + related_objects = set(self.settings["related_objects"]) + relating_object = self.settings["relating_object"] + is_nested_by = next((i for i in relating_object.IsNestedBy), None) + + previous_nests_rels: set[ifcopenshell.entity_instance] = set() + objects_without_nests: list[ifcopenshell.entity_instance] = [] + objects_with_nests: list[ifcopenshell.entity_instance] = [] + + # check if there is anything to change + for object in related_objects: + object_rel = next(iter(object.Nests), None) + + if object_rel is None: + objects_without_nests.append(object) + continue + + # either is_nested_by is None or product is part of different rel + if object_rel != is_nested_by: + previous_nests_rels.add(object_rel) + objects_with_nests.append(object) + + # products with already assigned nestings will be skipped + + objects_to_change = objects_without_nests + objects_with_nests + # nothing to change + if not objects_to_change: + return is_nested_by + + # NOTE: An object can both be nested and assigned to a container or aggregate. + + # unassign elements from previous nests + for nests in previous_nests_rels: + related_objects = set(nests.RelatedObjects) - related_objects if related_objects: - nests.RelatedObjects = related_objects + nests.RelatedObjects = list(related_objects) ifcopenshell.api.run("owner.update_owner_history", self.file, **{"element": nests}) else: history = nests.OwnerHistory @@ -121,10 +148,9 @@ class Usecase: if history: ifcopenshell.util.element.remove_deep2(self.file, history) + # assign elements to a new nesting if is_nested_by: - related_objects = list(is_nested_by.RelatedObjects) - related_objects.append(self.settings["related_object"]) - is_nested_by.RelatedObjects = related_objects + is_nested_by.RelatedObjects = list(set(is_nested_by.RelatedObjects) | related_objects) ifcopenshell.api.run("owner.update_owner_history", self.file, **{"element": is_nested_by}) else: is_nested_by = self.file.create_entity( @@ -132,8 +158,12 @@ class Usecase: **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.run("owner.create_owner_history", self.file), - "RelatedObjects": [self.settings["related_object"]], - "RelatingObject": self.settings["relating_object"], + "RelatedObjects": list(related_objects), + "RelatingObject": relating_object, } ) + + # NOTE: Creating a nesting relationship doesn't localize the object's placement, + # unlike assigning it to an aggregate or a container. + return is_nested_by diff --git a/src/ifcopenshell-python/ifcopenshell/api/nest/change_nest.py b/src/ifcopenshell-python/ifcopenshell/api/nest/change_nest.py index 98ba363050..6d83a9e4e8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/nest/change_nest.py +++ b/src/ifcopenshell-python/ifcopenshell/api/nest/change_nest.py @@ -44,6 +44,6 @@ class Usecase: ifcopenshell.api.run( "nest.assign_object", self.file, - related_object=self.settings["item"], + related_objects=[self.settings["item"]], relating_object=self.settings["new_parent"], ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/nest/unassign_object.py b/src/ifcopenshell-python/ifcopenshell/api/nest/unassign_object.py index bff97fd07a..e54fc918bc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/nest/unassign_object.py +++ b/src/ifcopenshell-python/ifcopenshell/api/nest/unassign_object.py @@ -42,8 +42,8 @@ class Usecase: task = ifcopenshell.api.run("root.create_entity", model, ifc_class="IfcSite") subtask1 = ifcopenshell.api.run("root.create_entity", model, ifc_class="IfcBuilding") subtask2 = ifcopenshell.api.run("root.create_entity", model, ifc_class="IfcBuilding") - ifcopenshell.api.run("nest.assign_object", model, related_object=subtask1, relating_object=task) - ifcopenshell.api.run("nest.assign_object", model, related_object=subtask2, relating_object=task) + ifcopenshell.api.run("nest.assign_object", model, related_objects=[subtask1], relating_object=task) + ifcopenshell.api.run("nest.assign_object", model, related_objects=[subtask2], relating_object=task) # The relationship is returned as task still has subtask2 rel = ifcopenshell.api.run("nest.unassign_object", model, related_object=subtask1) # Nothing is returned, as the relationship has no related objects diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource.py b/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource.py index 6a9ac81167..9580389ab6 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource.py @@ -97,7 +97,7 @@ class Usecase: ifcopenshell.api.run( "nest.assign_object", self.file, - related_object=resource, + related_objects=[resource], relating_object=self.settings["parent_resource"], ) else: diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_task.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_task.py index 80244df3a5..bf42853d2c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_task.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_task.py @@ -176,7 +176,7 @@ class Usecase: rel = ifcopenshell.api.run( "nest.assign_object", self.file, - related_object=task, + related_objects=[task], relating_object=self.settings["parent_task"], ) if self.settings["parent_task"].Identification: diff --git a/src/ifcopenshell-python/test/api/nest/__init__.py b/src/ifcopenshell-python/test/api/nest/__init__.py new file mode 100644 index 0000000000..8bed51a56f --- /dev/null +++ b/src/ifcopenshell-python/test/api/nest/__init__.py @@ -0,0 +1,17 @@ +# 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 . diff --git a/src/ifcopenshell-python/test/api/nest/test_assign_object.py b/src/ifcopenshell-python/test/api/nest/test_assign_object.py new file mode 100644 index 0000000000..df9757c2ce --- /dev/null +++ b/src/ifcopenshell-python/test/api/nest/test_assign_object.py @@ -0,0 +1,67 @@ +# 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 pytest +import test.bootstrap +import ifcopenshell.api +import ifcopenshell.util.element +import ifcopenshell.util.placement + + +class TestAssignObject(test.bootstrap.IFC4): + def test_assigning_a_nesting(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcSanitaryTerminal") + subelement1 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcValve") + subelement2 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcValve") + rel = ifcopenshell.api.run( + "nest.assign_object", self.file, related_objects=[subelement1, subelement2], relating_object=element + ) + assert ifcopenshell.util.element.get_nest(subelement1) == element + assert ifcopenshell.util.element.get_nest(subelement2) == element + assert rel.is_a("IfcRelNests") + + def test_doing_nothing_if_the_nesting_is_already_assigned(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcSanitaryTerminal") + subelement = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcValve") + ifcopenshell.api.run("nest.assign_object", self.file, related_objects=[subelement], relating_object=element) + total_elements = len([e for e in self.file]) + ifcopenshell.api.run("nest.assign_object", self.file, related_objects=[subelement], relating_object=element) + assert len([e for e in self.file]) == total_elements + + def test_that_old_nesting_relationships_are_updated_if_they_still_have_elements(self): + element1 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcSanitaryTerminal") + element2 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcSanitaryTerminal") + subelement1 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcValve") + subelement2 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcValve") + ifcopenshell.api.run( + "nest.assign_object", self.file, related_objects=[subelement1, subelement2], relating_object=element1 + ) + rel = subelement1.Nests[0] + assert len(rel.RelatedObjects) == 2 + ifcopenshell.api.run("nest.assign_object", self.file, related_objects=[subelement1], relating_object=element2) + assert len(rel.RelatedObjects) == 1 + + def test_that_old_nesting_relationships_are_purged_if_no_more_elements_are_nested(self): + element1 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcSanitaryTerminal") + element2 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcSanitaryTerminal") + subelement1 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcValve") + ifcopenshell.api.run("nest.assign_object", self.file, related_objects=[subelement1], relating_object=element1) + rel_id = subelement1.Nests[0].id() + ifcopenshell.api.run("nest.assign_object", self.file, related_objects=[subelement1], relating_object=element2) + with pytest.raises(RuntimeError): + self.file.by_id(rel_id) diff --git a/src/ifcopenshell-python/test/api/root/test_remove_product.py b/src/ifcopenshell-python/test/api/root/test_remove_product.py index 68ddc40a58..34756288bd 100644 --- a/src/ifcopenshell-python/test/api/root/test_remove_product.py +++ b/src/ifcopenshell-python/test/api/root/test_remove_product.py @@ -190,7 +190,7 @@ class TestRemoveProduct(test.bootstrap.IFC4): def test_removing_all_nesting_relationships_of_a_whole(self): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") subelement = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcBeam") - ifcopenshell.api.run("nest.assign_object", self.file, related_object=subelement, relating_object=element) + ifcopenshell.api.run("nest.assign_object", self.file, related_objects=[subelement], relating_object=element) total_entities = len(list(self.file)) ifcopenshell.api.run("root.remove_product", self.file, product=element) assert len(list(self.file)) == total_entities - 2 @@ -201,7 +201,7 @@ class TestRemoveProduct(test.bootstrap.IFC4): def test_removing_all_nesting_relationships_of_a_part(self): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") subelement = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcBeam") - ifcopenshell.api.run("nest.assign_object", self.file, related_object=subelement, relating_object=element) + ifcopenshell.api.run("nest.assign_object", self.file, related_objects=[subelement], relating_object=element) total_entities = len(list(self.file)) ifcopenshell.api.run("root.remove_product", self.file, product=subelement) assert len(list(self.file)) == total_entities - 2 diff --git a/src/ifcopenshell-python/test/api/test_api.py b/src/ifcopenshell-python/test/api/test_api.py index ccd55141ae..c900471ade 100644 --- a/src/ifcopenshell-python/test/api/test_api.py +++ b/src/ifcopenshell-python/test/api/test_api.py @@ -107,3 +107,11 @@ class TestTemporarySupportForDeprecatedAPIArguments(test.bootstrap.IFC4): ifcopenshell.api.run("spatial.assign_container", self.file, products=[subelement], relating_structure=element) ifcopenshell.api.run("spatial.remove_container", self.file, product=subelement) assert ifcopenshell.util.element.get_container(subelement) is None + + @deprecation_check + def test_assigning_a_nesting(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcSanitaryTerminal") + subelement = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcValve") + rel = ifcopenshell.api.run("nest.assign_object", self.file, related_object=subelement, relating_object=element) + assert ifcopenshell.util.element.get_nest(subelement) == element + assert rel.is_a("IfcRelNests") diff --git a/src/ifctester/test/test_facet.py b/src/ifctester/test/test_facet.py index 0c93fbdc52..65c6cfc8d2 100644 --- a/src/ifctester/test/test_facet.py +++ b/src/ifctester/test/test_facet.py @@ -1526,7 +1526,7 @@ class TestPartOf: ifc = ifcopenshell.file() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcFurniture") subelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcDiscreteAccessory") - ifcopenshell.api.run("nest.assign_object", ifc, related_object=subelement, relating_object=element) + ifcopenshell.api.run("nest.assign_object", ifc, related_objects=[subelement], relating_object=element) facet = PartOf(name="IFCFURNITURE", relation="IFCRELNESTS") run("Any nested part passes a nest relationship", facet=facet, inst=subelement, expected=True) run("Any nested whole fails a nest relationship", facet=facet, inst=element, expected=False) @@ -1534,7 +1534,7 @@ class TestPartOf: ifc = ifcopenshell.file() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcFurniture") subelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcDiscreteAccessory") - ifcopenshell.api.run("nest.assign_object", ifc, related_object=subelement, relating_object=element) + ifcopenshell.api.run("nest.assign_object", ifc, related_objects=[subelement], relating_object=element) facet = PartOf(relation="IFCRELNESTS", name="IFCBEAM") run("The nest entity must match exactly 1/2", facet=facet, inst=subelement, expected=False) facet = PartOf(relation="IFCRELNESTS", name="IFCFURNITURE") @@ -1551,8 +1551,8 @@ class TestPartOf: element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcFurniture") subelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcDiscreteAccessory") subsubelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcMechanicalFastener") - ifcopenshell.api.run("nest.assign_object", ifc, related_object=subelement, relating_object=element) - ifcopenshell.api.run("nest.assign_object", ifc, related_object=subsubelement, relating_object=subelement) + ifcopenshell.api.run("nest.assign_object", ifc, related_objects=[subelement], relating_object=element) + ifcopenshell.api.run("nest.assign_object", ifc, related_objects=[subsubelement], relating_object=subelement) facet = PartOf(relation="IFCRELNESTS", name="IFCFURNITURE") run("Nesting may be indirect", facet=facet, inst=subsubelement, expected=True)