Compare commits

...

1 Commits

Author SHA1 Message Date
Petru Conduraru 9a02b308fd Append aggregates with their parts, sub-aggregates and openings (#5909)
bim.append_library_element_by_query only ever brought in the top-level
element of an assembly. Two layers needed fixing:

ifcopenshell.api.project.append_asset never whitelisted IsDecomposedBy
for IfcElement, so IfcRelAggregates was not walked, and even then the
is_another_asset guard filtered aggregated children because they share
the IfcProduct target class with the top asset. IfcRelAggregates is now
treated as a dependent relationship, the same way IfcRelVoidsElement and
IfcRelProjectsElement already are (precedent: 55e97fd170). Scoped to
IfcElement so spatial decomposition is untouched.

The Bonsai AppendLibraryElement operator then only created a viewport
object for the single top element, leaving appended parts invisible in
the 3D view. It now creates objects, materials, styles and types for
every part and sub-assembly via get_decomposition, filtering openings
the same way normal import does.

This change was written with AI assistance.
2026-07-21 12:27:16 +03:00
3 changed files with 61 additions and 24 deletions
@@ -637,10 +637,12 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
elif element.is_a("IfcProduct"):
# NOTE: Non-types are not exposed in UI directly
# but the code is still used when appending products by query.
self.import_product_from_ifc(element, context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type is not None and tool.Ifc.get_object(element_type) is None:
self.import_type_from_ifc(element_type, context)
elements = self.get_appended_elements(element)
self.import_product_from_ifc(elements, context)
for appended_element in elements:
element_type = ifcopenshell.util.element.get_type(appended_element)
if element_type is not None and tool.Ifc.get_object(element_type) is None:
self.import_type_from_ifc(element_type, context)
elif element.is_a("IfcMaterial"):
self.import_material_from_ifc(element, context)
elif element.is_a("IfcSurfaceStyle"):
@@ -676,17 +678,32 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
ifc_importer.file = self.file
ifc_importer.create_style(style)
def import_product_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None:
def get_appended_elements(self, element: ifcopenshell.entity_instance) -> set[ifcopenshell.entity_instance]:
"""``element`` plus any parts brought in with it (e.g. the parts of an appended assembly)."""
elements = {element}
for part in ifcopenshell.util.element.get_decomposition(element):
if not part.is_a("IfcFeatureElement") or part.is_a("IfcSurfaceFeature"):
elements.add(part)
return elements
def import_product_from_ifc(
self,
elements: Union[ifcopenshell.entity_instance, set[ifcopenshell.entity_instance]],
context: bpy.types.Context,
) -> None:
self.file = tool.Ifc.get()
if isinstance(elements, ifcopenshell.entity_instance):
elements = {elements}
logger = logging.getLogger("ImportIFC")
ifc_import_settings = import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger)
ifc_importer = import_ifc.IfcImporter(ifc_import_settings)
ifc_importer.file = self.file
ifc_importer.process_context_filter()
ifc_importer.material_creator.load_existing_materials()
self.import_materials(element, ifc_importer)
self.import_styles(element, ifc_importer)
ifc_importer.create_generic_elements({element})
for element in elements:
self.import_materials(element, ifc_importer)
self.import_styles(element, ifc_importer)
ifc_importer.create_generic_elements(elements)
ifc_importer.place_objects_in_collections()
def import_type_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None:
@@ -2772,10 +2789,12 @@ class AppendInspectedLinkedElement(AppendLibraryElement):
library=linked_ifc_file,
element=element_to_append,
)
self.import_product_from_ifc(element, context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type and tool.Ifc.get_object(element_type) is None:
self.import_type_from_ifc(element_type, context)
elements = self.get_appended_elements(element)
self.import_product_from_ifc(elements, context)
for appended_element in elements:
element_type = ifcopenshell.util.element.get_type(appended_element)
if element_type and tool.Ifc.get_object(element_type) is None:
self.import_type_from_ifc(element_type, context)
return {"FINISHED"}
@@ -397,7 +397,7 @@ class Usecase:
self.whitelisted_inverse_attributes = {
"IfcObjectDefinition": ["HasAssociations"],
"IfcObject": ["IsDefinedBy.IfcRelDefinesByProperties"],
"IfcElement": ["HasOpenings"],
"IfcElement": ["HasOpenings", "IsDecomposedBy"],
"IfcDistributionElement": ["IsNestedBy"],
self.base_material_class: ["HasExternalReferences", "HasProperties", "HasRepresentation"],
"IfcRepresentationItem": [
@@ -529,24 +529,18 @@ class Usecase:
new = self.file.create_entity(element.is_a())
self.reuse_identities[element_identity] = new
# Void, projection, and aggregation relationships are "dependent" and always considered.
is_dependent_rel = element.is_a() in ("IfcRelVoidsElement", "IfcRelProjectsElement", "IfcRelAggregates")
for i, attribute in enumerate(element):
new_attribute = None
if isinstance(attribute, ifcopenshell.entity_instance):
# Void and projection relationships are special because they
# are "dependent" relationships, so we always consider them.
# We do _not_ whitelist (i.e. in is_another_asset)
# IfcFeatureElement because you can have things like
# IfcRelAssociatesClassification to openings! We only ever want
# to consider IfcFeatureElements in IfcRelVoidsElements and
# IfcRelProjectsElements.
if element.is_a() in ("IfcRelVoidsElement", "IfcRelProjectsElement") or not self.is_another_asset(
attribute
):
if is_dependent_rel or not self.is_another_asset(attribute):
new_attribute = self.add_element(attribute)
elif isinstance(attribute, tuple) and attribute and isinstance(attribute[0], ifcopenshell.entity_instance):
new_attribute = []
for item in attribute:
if self.is_another_asset(item):
if not is_dependent_rel and self.is_another_asset(item):
continue
if skip_not_reused_entities_attr_i is not None and i == skip_not_reused_entities_attr_i:
identity = item.wrapped_data.identity()
@@ -18,6 +18,7 @@
import numpy as np
import ifcopenshell.api.aggregate
import ifcopenshell.api.classification
import ifcopenshell.api.context
import ifcopenshell.api.cost
@@ -409,6 +410,29 @@ class TestAppendAssetIFC2X3(test.bootstrap.IFC2X3):
ifcopenshell.api.project.append_asset(self.file, library=library, element=element)
assert self.file.by_type("IfcWall")[0].HasOpenings[0].RelatedOpeningElement.is_a("IfcOpeningElement")
def test_append_an_aggregate_with_its_parts_and_sub_aggregates_and_openings(self):
library = ifcopenshell.api.project.create_file(version=self.file.schema)
top = ifcopenshell.api.root.create_entity(library, ifc_class="IfcElementAssembly", name="House_Module")
sub_assembly = ifcopenshell.api.root.create_entity(
library, ifc_class="IfcElementAssembly", name="Wall_Panel_SubAssembly"
)
part = ifcopenshell.api.root.create_entity(library, ifc_class="IfcWall", name="Panel Wall")
opening = ifcopenshell.api.root.create_entity(library, ifc_class="IfcOpeningElement")
ifcopenshell.api.feature.add_feature(library, feature=opening, element=part)
ifcopenshell.api.aggregate.assign_object(library, relating_object=sub_assembly, products=[part])
ifcopenshell.api.aggregate.assign_object(library, relating_object=top, products=[sub_assembly])
appended = ifcopenshell.api.project.append_asset(self.file, library=library, element=top)
assert len(self.file.by_type("IfcElementAssembly")) == 2
assert len(self.file.by_type("IfcWall")) == 1
assert len(self.file.by_type("IfcOpeningElement")) == 1
appended_sub = appended.IsDecomposedBy[0].RelatedObjects[0]
assert appended_sub.is_a("IfcElementAssembly")
appended_part = appended_sub.IsDecomposedBy[0].RelatedObjects[0]
assert appended_part.is_a("IfcWall")
assert appended_part.HasOpenings[0].RelatedOpeningElement.is_a("IfcOpeningElement")
def test_append_a_product_with_unrelated_relationships_to_openings(self):
library = ifcopenshell.api.project.create_file(version=self.file.schema)
ifcopenshell.api.root.create_entity(library, ifc_class="IfcProject")