diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_shape_aspect.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_shape_aspect.py new file mode 100644 index 0000000000..2f4ee941fe --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_shape_aspect.py @@ -0,0 +1,88 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 +from typing import Optional + + +def add_shape_aspect( + file: ifcopenshell.file, + name: str, + items: list[ifcopenshell.entity_instance], + representation: ifcopenshell.entity_instance, + part_of_product: ifcopenshell.entity_instance, + description: Optional[str] = None, +) -> ifcopenshell.entity_instance: + """Adds a shape aspect to items that are part of a representation and product + + Existing shape aspects will be reused where possible. If the items already + belong to another shape aspect with a different name, this relationship + will be purged. + + Warning: it is not possible to add a shape aspect to types (i.e. + IfcRepresentationMap) in IFC2X3. + + :param name: The name of the shape aspect. This is case sensitive. + :param items: IfcRepresentationItems that will be assigned to this aspect. + :param representation: The IfcShapeRepresentation that the items are in. + :param part_of_product: The IfcRepresentationMap or + IfcProductDefinitionShape that the representation is in. + :param description: A description to set for the shape aspect. It's usually + not necessary. + :return: The IfcShapeAspect + """ + result = None + items_set = set(items) + for aspect in part_of_product.HasShapeAspects or []: + if aspect.Name == name: + for aspect_rep in aspect.ShapeRepresentations: + if aspect_rep.ContextOfItems == representation.ContextOfItems: + aspect.Description = description + aspect_rep.Items = tuple(set(aspect_rep.Items) | items_set) + result = aspect + if not result: + aspect_rep = file.createIfcShapeRepresentation( + ContextOfItems=representation.ContextOfItems, + RepresentationIdentifier=representation.RepresentationIdentifier, + RepresentationType=representation.RepresentationType, + Items=items, + ) + aspect.ShapeRepresentations += (aspect_rep,) + result = aspect + else: + for aspect_rep in aspect.ShapeRepresentations: + if aspect_rep.ContextOfItems != representation.ContextOfItems: + continue + if set(aspect_rep.Items) & items_set: + if new_items := set(aspect_rep.Items) - items_set: + aspect_rep.Items = tuple(new_items) + else: + file.remove(aspect_rep) + if not aspect.ShapeRepresentations: + file.remove(aspect) + + if result: + return result + + aspect_rep = file.createIfcShapeRepresentation( + ContextOfItems=representation.ContextOfItems, + RepresentationIdentifier=representation.RepresentationIdentifier, + RepresentationType=representation.RepresentationType, + Items=items, + ) + return file.createIfcShapeAspect((aspect_rep,), name, description, True, part_of_product) diff --git a/src/ifcopenshell-python/test/api/geometry/test_add_shape_aspect.py b/src/ifcopenshell-python/test/api/geometry/test_add_shape_aspect.py new file mode 100644 index 0000000000..1ee1b3ac87 --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_add_shape_aspect.py @@ -0,0 +1,161 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 test.bootstrap +import ifcopenshell.api.root +import ifcopenshell.api.context +import ifcopenshell.api.geometry + + +class TestAddShapeAspect(test.bootstrap.IFC4): + def test_adding_a_shape_aspect(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + body = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model + ) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + item = builder.sphere() + rep = builder.get_representation(body, [item]) + element = ifcopenshell.api.root.create_entity(self.file) + ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep) + + aspect = ifcopenshell.api.geometry.add_shape_aspect( + self.file, name="Foo", items=[item], representation=rep, part_of_product=element.Representation + ) + assert aspect.is_a("IfcShapeAspect") + assert aspect.PartOfProductDefinitionShape == element.Representation + assert aspect.Name == "Foo" + assert len(aspect.ShapeRepresentations) == 1 + aspect_rep = aspect.ShapeRepresentations[0] + assert aspect_rep != rep + assert aspect_rep.ContextOfItems == rep.ContextOfItems + assert aspect_rep.RepresentationIdentifier == rep.RepresentationIdentifier + assert aspect_rep.RepresentationType == rep.RepresentationType + assert aspect_rep.Items == (item,) + + def test_adding_a_type_shape_aspect(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + body = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model + ) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + item = builder.sphere() + rep = builder.get_representation(body, [item]) + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep) + + aspect = ifcopenshell.api.geometry.add_shape_aspect( + self.file, name="Foo", items=[item], representation=rep, part_of_product=element.RepresentationMaps[0] + ) + assert aspect.is_a("IfcShapeAspect") + assert aspect.PartOfProductDefinitionShape == element.RepresentationMaps[0] + assert aspect.Name == "Foo" + assert len(aspect.ShapeRepresentations) == 1 + aspect_rep = aspect.ShapeRepresentations[0] + assert aspect_rep != rep + assert aspect_rep.ContextOfItems == rep.ContextOfItems + assert aspect_rep.RepresentationIdentifier == rep.RepresentationIdentifier + assert aspect_rep.RepresentationType == rep.RepresentationType + assert aspect_rep.Items == (item,) + + def test_reusing_an_existing_aspect(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + body = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model + ) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + item = builder.sphere() + item2 = builder.sphere() + rep = builder.get_representation(body, [item, item2]) + element = ifcopenshell.api.root.create_entity(self.file) + ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep) + + aspect = ifcopenshell.api.geometry.add_shape_aspect( + self.file, name="Foo", items=[item], representation=rep, part_of_product=element.Representation + ) + aspect2 = ifcopenshell.api.geometry.add_shape_aspect( + self.file, name="Foo", items=[item2], representation=rep, part_of_product=element.Representation + ) + assert aspect == aspect2 + assert len(aspect.ShapeRepresentations) == 1 + assert set(aspect.ShapeRepresentations[0].Items) == {item, item2} + + def test_removing_from_previous_aspects(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + body = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model + ) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + item = builder.sphere() + item2 = builder.sphere() + rep = builder.get_representation(body, [item, item2]) + element = ifcopenshell.api.root.create_entity(self.file) + ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep) + + aspect = ifcopenshell.api.geometry.add_shape_aspect( + self.file, name="Foo", items=[item, item2], representation=rep, part_of_product=element.Representation + ) + aspect2 = ifcopenshell.api.geometry.add_shape_aspect( + self.file, name="Bar", items=[item2], representation=rep, part_of_product=element.Representation + ) + assert aspect != aspect2 + assert aspect.Name == "Foo" + assert aspect.ShapeRepresentations[0].Items == (item,) + assert aspect2.Name == "Bar" + assert aspect2.ShapeRepresentations[0].Items == (item2,) + + def test_take_context_into_account(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + body = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model + ) + box = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Box", target_view="MODEL_VIEW", parent=model + ) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + item = builder.sphere() + item2 = builder.sphere() + rep = builder.get_representation(body, [item, item2]) + rep2 = builder.get_representation(box, [item, item2]) + element = ifcopenshell.api.root.create_entity(self.file) + ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep) + ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep2) + + aspect = ifcopenshell.api.geometry.add_shape_aspect( + self.file, name="Foo", items=[item, item2], representation=rep, part_of_product=element.Representation + ) + aspect2 = ifcopenshell.api.geometry.add_shape_aspect( + self.file, name="Foo", items=[item2], representation=rep2, part_of_product=element.Representation + ) + assert aspect == aspect2 + assert aspect.Name == "Foo" + assert len(aspect.ShapeRepresentations) == 2 + assert aspect.ShapeRepresentations[0].ContextOfItems == rep.ContextOfItems + assert aspect.ShapeRepresentations[1].ContextOfItems == rep2.ContextOfItems + assert set(aspect.ShapeRepresentations[0].Items) == {item, item2} + assert aspect.ShapeRepresentations[1].Items == (item2,) + + +class TestAddShapeAspectIFC2X3(test.bootstrap.IFC2X3, TestAddShapeAspect): + def test_adding_a_type_shape_aspect(self): + pass # Not allowed