From 3483683cb4a986f2e50a403c09dfdd10c18d0474 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 27 May 2026 00:04:03 +0200 Subject: [PATCH] Add tool.Geometry helpers for body representation + placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: * get_body_representation(element) — DRY of the repeated ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") call across slab / wall / opening / stair / roof / door / window / mep. One central place to read the body rep; every caller stops re-spelling the four magic strings. * has_axis_representation(element) — predicate for elements with a GRAPH_VIEW Axis representation. Used by the wall/MEP path decorators to skip elements without an unambiguous 1D path. * has_material_styles(element) — predicate for whether the element carries IfcStyledItem material assignments. * restore_placement_from_ifc(obj, element) — snap obj.matrix_world back to element's committed IFC placement + rebaseline the drift checksum. * restore_or_rebaseline_placement(obj, element) — Cancel-flow helper: restores if ObjectPlacement exists, just rebaselines the checksum if not. * detach_representation(product) — remove the active representation from a product without deleting the entity (used by parametric rebuilds that wipe + re-add). commit_placement_if_moved docstring expanded with a "drop-in scope" note so callers don't redundantly wrap it in an is_moved check that the helper already does. Switches the duplicate-aware helper calls (formerly tool.Root.*) to tool.Duplicate.* now that the service exists (C6). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/geometry.py | 129 ++++++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 13 deletions(-) diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 0d690d0308..4fe0fdc7ab 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -73,7 +73,7 @@ import bonsai.core.style import bonsai.core.system import bonsai.core.tool import bonsai.tool as tool -from bonsai.bim.ifc import IfcStore +from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock if TYPE_CHECKING: from bonsai.bim.module.geometry.prop import ( @@ -115,10 +115,42 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def clear_cache(cls, element: ifcopenshell.entity_instance) -> None: - cache = IfcStore.get_cache() + # Cache acquisition can fail if the HDF5 file is locked by another + # process — degrade gracefully rather than aborting the caller's + # reimport flow. A stale cache entry is harmless; a raised exception + # prevents the actual mesh swap. The wrapper sets the project-panel + # warning flag on lock so the user sees one prominent notice instead + # of per-element log spam. + try: + cache = get_cache_or_detect_lock() + except Exception as exc: + print(f"clear_cache: skipping cache invalidation for {element} ({exc})") + return if cache and hasattr(element, "GlobalId"): cache.remove(element.GlobalId) + @classmethod + def has_axis_representation(cls, element: ifcopenshell.entity_instance) -> bool: + """True if the element carries a shape representation whose + RepresentationIdentifier is 'Axis'. Elements without one cannot be + projected to an unambiguous 1D path; callers that draw schematic axis + overlays must skip them rather than fall back to mesh-derived geometry.""" + product_rep = getattr(element, "Representation", None) + if product_rep is None: + return False + for rep in product_rep.Representations: + if getattr(rep, "RepresentationIdentifier", None) == "Axis": + return True + return False + + @classmethod + def get_body_representation(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None: + """The element's ``Model/Body/MODEL_VIEW`` representation, or ``None``. + Single source for the ``(context, identifier, target_view)`` triple used + by every body-geometry reader across walls, slabs, doors, openings, and + feature decorators.""" + return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + @classmethod def clear_modifiers(cls, obj: bpy.types.Object) -> None: for modifier in obj.modifiers: @@ -788,6 +820,15 @@ class Geometry(bonsai.core.tool.Geometry): return True return False + @classmethod + def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool: + """True when any of ``element``'s materials exposes an + ``IfcSurfaceStyle``. Gate body-style assignment to avoid double-styling.""" + return any( + tool.Material.get_style(material) is not None + for material in ifcopenshell.util.element.get_materials(element) + ) + @classmethod def reimport_element_representations( cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance, apply_openings: bool = True @@ -1154,6 +1195,53 @@ class Geometry(bonsai.core.tool.Geometry): props.location_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes()) props.rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes()) + @classmethod + def commit_placement_if_moved(cls, obj: bpy.types.Object, *, apply_scale: bool = True) -> None: + """Write ``obj.matrix_world`` back to its IFC ``ObjectPlacement`` when the + object has drifted since its last placement commit. + + Scope: drop-in only when the gate is exactly ``is_moved(obj)``. Call sites + whose gate is wider (e.g. ``is_moved OR is_scaled``) or already enforced + upstream (inside an ``if is_moved:`` block) should call + ``edit_object_placement`` directly to avoid the redundant inner check.""" + if not tool.Ifc.is_moved(obj): + return + bonsai.core.geometry.edit_object_placement( + tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=apply_scale + ) + + @classmethod + def restore_placement_from_ifc(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: + """Snap ``obj.matrix_world`` back to ``element``'s committed IFC placement, + then re-baseline the drift checksum so ``tool.Ifc.is_moved(obj)`` returns + False afterwards. + + Precondition: ``element.ObjectPlacement`` must not be None. Callers in a + cancel-style flow that want a "restore-or-clear-drift" semantic must gate + on ObjectPlacement themselves and call ``record_object_position`` directly + in the no-placement branch.""" + assert element.ObjectPlacement is not None, ( + "restore_placement_from_ifc requires ObjectPlacement — gate the caller " + "or use restore_or_rebaseline_placement for the restore-or-clear-drift semantic" + ) + matrix_np = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement).copy() + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + matrix_np[:3, 3] *= unit_scale + obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix_np) + cls.record_object_position(obj) + + @classmethod + def restore_or_rebaseline_placement(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: + """Cancel-flow placement restore: revert ``obj.matrix_world`` to the committed + IFC placement; when the element has no ObjectPlacement, re-baseline the drift + checksum instead so a subsequent edit does not silently commit the discarded drag.""" + if not tool.Ifc.is_moved(obj): + return + if element.ObjectPlacement is None: + cls.record_object_position(obj) + return + cls.restore_placement_from_ifc(obj, element) + @classmethod def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None: tool.Ifc.get().remove(connection) @@ -1205,6 +1293,20 @@ class Geometry(bonsai.core.tool.Geometry): bpy.data.objects.remove(obj) return new_obj + @classmethod + def detach_representation(cls, product: ifcopenshell.entity_instance) -> None: + """Replace ``product.Representation`` with a deep copy so the product + no longer shares its representation tree (mapped or direct) with any + other entity. The ``IfcGeometricRepresentationContext`` is excluded + from the copy so contexts stay file-singletons. No-op when the + product has no ``Representation`` attribute or it is unset.""" + rep = getattr(product, "Representation", None) + if rep is None: + return + product.Representation = ifcopenshell.util.element.copy_deep( + tool.Ifc.get(), rep, exclude=["IfcGeometricRepresentationContext"] + ) + @classmethod def resolve_mapped_representation( cls, representation: ifcopenshell.entity_instance @@ -2132,8 +2234,11 @@ class Geometry(bonsai.core.tool.Geometry): new_active_obj = None # Track decompositions so they can be recreated after the operation - decomposition_relationships = tool.Root.get_decomposition_relationships(objects_to_duplicate) - connection_relationships = tool.Root.get_connection_relationships(objects_to_duplicate) + decomposition_relationships = tool.Duplicate.get_decomposition_relationships(objects_to_duplicate) + connection_relationships = tool.Duplicate.get_connection_relationships(objects_to_duplicate) + # Snapshot port-to-port connections — copy_class disconnects new ports + # by default, leaving Shift+D duplicates unconnected. + port_connection_snapshot = tool.Duplicate.get_port_connection_relationships(objects_to_duplicate) old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {} old_obj_name_to_new_obj_name: dict[str, str] = {} @@ -2155,10 +2260,7 @@ class Geometry(bonsai.core.tool.Geometry): keep_data_linked = linked and not element and not is_tracked_opening # Prior to duplicating, sync the object placement to make decomposition recreation more stable. - if tool.Ifc.is_moved(obj): - bonsai.core.geometry.edit_object_placement( - tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=False - ) + cls.commit_placement_if_moved(obj, apply_scale=False) new_obj = obj.copy() temp_data = None @@ -2212,7 +2314,7 @@ class Geometry(bonsai.core.tool.Geometry): array_data = arrays_to_duplicate.get(obj, None) tool.Model.handle_array_on_copied_element(new, array_data) if array_data: - for child in tool.Blender.Modifier.Array.get_all_children_objects(new): + for child in tool.Array.get_all_children_objects(new): child.select_set(True) # TODO: add new array children to recreate their decomposition too @@ -2240,10 +2342,11 @@ class Geometry(bonsai.core.tool.Geometry): # Remove connections with old objects and recreates paths cls.remove_old_connections(old_to_new) - tool.Root.recreate_connections(connection_relationships, old_to_new) + tool.Duplicate.recreate_connections(connection_relationships, old_to_new) + tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new) # Recreate decompositions - tool.Root.recreate_decompositions(decomposition_relationships, old_to_new) + tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new) cls.remove_linked_aggregate_data(old_to_new) bonsai.bim.handler.refresh_ui_data() tool.Root.reload_grid_decorator() @@ -2308,8 +2411,8 @@ class Geometry(bonsai.core.tool.Geometry): continue array_data = [] - for modifier_data in tool.Blender.Modifier.Array.get_modifiers_data(array_parent): - children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data)) + for modifier_data in tool.Array.get_modifiers_data(array_parent): + children = set(tool.Array.get_children_objects(modifier_data)) if children.issubset(selected_objects): modifier_data["children"] = [] array_data.append(modifier_data)