From df35a8954bd39ca0a4612a965b420a8e5f97a03a Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 29 Jul 2026 10:29:34 -0500 Subject: [PATCH] Bonsai: hand the duplicated annotation to the duplicated product (#9049) Case A only removed the new element from the original product's relationship. That was enough while copy_class also cloned the product's relationship onto the duplicate, because case B then stripped the stale original from it and the duplicate was left holding the new annotation. PR #9054 stops copy_class sharing annotations that way, since that sharing is what leaves one annotation assigned to two products (#4014, #9049). With that clone gone, nothing recreates the link, and duplicating a product together with its annotation leaves the annotation copy assigned to no product at all, which is worse than the duplicate it replaced. Case A now follows the product: when the relating product was duplicated in the same operation, the new element is assigned to the corresponding copy, pairing them by index so the i-th copy of an annotation labels the i-th copy of the product. It appends to the new product's existing relationship when it has one and creates a fresh one otherwise, and skips entirely when the element is already assigned to that product, which is what happens for related objects copy_class still carries over, such as the task that outputs the product. That last guard is what makes this safe to merge in either order relative to #9054. Verified over five scenarios against both the old and the new copy_class: duplicating product and annotation together, the annotation alone, the product alone, and the two task equivalents so the 4D output link is not disturbed. With the old copy_class every case this PR already handled still passes; the only failure is duplicating the product alone, which is the bug #9054 fixes and which this PR never covered. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Opus 5 --- src/bonsai/bonsai/tool/geometry.py | 35 ++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 4457e8e4ca..4762cac94d 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -2837,9 +2837,16 @@ class Geometry(bonsai.core.tool.Geometry): causing both old and new products to share stale RelatedObjects lists. Three cases, identified by whether the RelatingProduct was also duplicated: - A) RelatingProduct is OLD → new annotation was incorrectly appended → remove it. - B) RelatingProduct is NEW → stale original annotation was carried over → remove it. + A) RelatingProduct is OLD → new annotation was incorrectly appended → remove it, + and hand it to the duplicate of that product, which is where it belongs. + B) RelatingProduct is NEW → stale original was carried over → remove it. C) RelatingProduct unchanged → create a genuinely new rel for the new annotation. + + Case A used to only remove, because copy_class also cloned the product's rel onto + the duplicate, so case B did the re-linking. copy_class no longer shares annotations + that way (#9049), so case A has to make the link itself or the duplicated annotation + would end up assigned to nothing. Case B still fires for related objects that are + not annotations, such as the task that outputs the product. """ ifc_file = tool.Ifc.get() old_elements = set(old_to_new.keys()) @@ -2855,6 +2862,30 @@ class Geometry(bonsai.core.tool.Geometry): if relating_product in old_elements: # Case A: remove new elem from the old product's rel rel.RelatedObjects = [e for e in rel.RelatedObjects if e != new_elem] + # The product was duplicated too, so follow it: the i-th copy of the + # annotation belongs to the i-th copy of the product it labels. + new_products = old_to_new.get(relating_product) or [] + index = old_to_new[original_elem].index(new_elem) + if index < len(new_products): + new_product = new_products[index] + if not any( + r.is_a("IfcRelAssignsToProduct") and r.RelatingProduct == new_product + for r in new_elem.HasAssignments or [] + ): + new_rel = next( + (r for r in new_product.ReferencedBy or [] if r.is_a("IfcRelAssignsToProduct")), + None, + ) + if new_rel: + new_rel.RelatedObjects = list(new_rel.RelatedObjects) + [new_elem] + else: + ifc_file.create_entity( + "IfcRelAssignsToProduct", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=rel.OwnerHistory, + RelatedObjects=[new_elem], + RelatingProduct=new_product, + ) elif relating_product in new_elements: # Case B: remove the stale original from the new product's rel rel.RelatedObjects = [e for e in rel.RelatedObjects if e != original_elem]