Support appending IfcGroup assets via project.append_asset (#8162)

append_asset could not import an IfcGroup from a project library: IfcGroup
was absent from APPENDABLE_ASSET, so execute() fell through every branch
and returned None, appending nothing.

Add IfcGroup to APPENDABLE_ASSET and an append_group() routine that mirrors
append_product(): it whitelists the group's IsGroupedBy inverse so the
members reached through IfcRelAssignsToGroup are copied via the regular
element-append path (bringing their materials, properties and
representations), and the relationship is recreated in the destination
linking the copied group to the copied members. Idempotency comes from the
existing GUID-based reuse, so re-appending the same group does not
duplicate it.

Verified headless: appending a group of three walls yields group=1,
walls=3, IfcRelAssignsToGroup=1 with RelatingGroup and RelatedObjects
correctly wired; re-appending is a no-op; appending a plain IfcProduct
still works unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Petru Conduraru
2026-07-11 09:13:38 +03:00
parent 256d5a63f1
commit eb9591e9a2
@@ -39,6 +39,7 @@ APPENDABLE_ASSET = Literal[
"IfcCostSchedule", "IfcCostSchedule",
"IfcProfileDef", "IfcProfileDef",
"IfcPresentationStyle", "IfcPresentationStyle",
"IfcGroup",
] ]
APPENDABLE_ASSET_TYPES: tuple[APPENDABLE_ASSET, ...] = get_args(APPENDABLE_ASSET) APPENDABLE_ASSET_TYPES: tuple[APPENDABLE_ASSET, ...] = get_args(APPENDABLE_ASSET)
MATERIAL_SETS = ("IfcMaterialLayerSet", "IfcMaterialConstituentSet", "IfcMaterialProfileSet") MATERIAL_SETS = ("IfcMaterialLayerSet", "IfcMaterialConstituentSet", "IfcMaterialProfileSet")
@@ -68,8 +69,8 @@ def append_asset(
:param library: The file object containing the asset. :param library: The file object containing the asset.
:param element: An element in the library file of the asset. It may be :param element: An element in the library file of the asset. It may be
an IfcTypeProduct, IfcProduct, IfcMaterial, IfcCostSchedule, or an IfcTypeProduct, IfcProduct, IfcMaterial, IfcCostSchedule,
IfcProfileDef. IfcProfileDef, or IfcGroup.
:param reuse_identities: Optional dictionary of mapped entities' identities to the :param reuse_identities: Optional dictionary of mapped entities' identities to the
already created elements. It will be used to avoid creating already created elements. It will be used to avoid creating
duplicated inverse elements during multiple `project.append_asset` calls. If you want duplicated inverse elements during multiple `project.append_asset` calls. If you want
@@ -236,6 +237,9 @@ class Usecase:
elif self.settings["element"].is_a("IfcPresentationStyle"): elif self.settings["element"].is_a("IfcPresentationStyle"):
self.target_class = "IfcPresentationStyle" self.target_class = "IfcPresentationStyle"
return self.append_presentation_style() return self.append_presentation_style()
elif self.settings["element"].is_a("IfcGroup"):
self.target_class = "IfcGroup"
return self.append_group()
def by_guid(self, guid: str) -> Union[ifcopenshell.entity_instance, None]: def by_guid(self, guid: str) -> Union[ifcopenshell.entity_instance, None]:
try: try:
@@ -393,6 +397,33 @@ class Usecase:
self.reuse_existing_contexts() self.reuse_existing_contexts()
return element return element
def append_group(self):
# A group (IfcGroup and subtypes like IfcSystem, IfcZone) is appended
# together with its members. Members are reached through the group's
# ``IsGroupedBy`` inverse (the IfcRelAssignsToGroup relationship), whose
# ``RelatedObjects`` are copied via the regular element-append path, so
# they bring their own materials, properties, and representations. The
# relationship itself is recreated in the destination linking the copied
# group to the copied members.
self.whitelisted_inverse_attributes = {
"IfcObjectDefinition": ["HasAssociations"],
"IfcObject": ["IsDefinedBy.IfcRelDefinesByProperties"],
"IfcGroup": ["IsGroupedBy"],
"IfcElement": ["HasOpenings"],
self.base_material_class: ["HasExternalReferences", "HasProperties", "HasRepresentation"],
"IfcRepresentationItem": [
"StyledByItem",
"LayerAssignments" if self.file.schema == "IFC2X3" else "LayerAssignment",
],
"IfcRepresentation": ["LayerAssignments"],
"IfcProductDefinitionShape": ["HasShapeAspects"],
"IfcRepresentationMap": ["HasShapeAspects"],
}
self.existing_contexts = self.file.by_type("IfcGeometricRepresentationContext")
element = self.add_element(self.settings["element"])
self.reuse_existing_contexts()
return element
def append_product(self): def append_product(self):
self.whitelisted_inverse_attributes = { self.whitelisted_inverse_attributes = {
"IfcObjectDefinition": ["HasAssociations"], "IfcObjectDefinition": ["HasAssociations"],