diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 9bc0566532..7ab9ff91a9 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -2603,19 +2603,27 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): return old_profile = item.SweptArea - profile.ProfileName = old_profile.ProfileName - for inverse in tool.Ifc.get().get_inverse(old_profile): - ifcopenshell.util.element.replace_attribute(inverse, old_profile, profile) - tool.Profile.replace_profile_in_profiles_ui(old_profile.id(), profile.id()) - ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_profile) + profile_unchanged = tool.Model.profile_shape_is_unchanged(old_profile, profile) + if profile_unchanged: + # Keep the parametric profile that an edit mode round trip would otherwise + # rewrite as an arbitrary profile (see #6481). + ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), profile) + profile = old_profile + else: + profile.ProfileName = old_profile.ProfileName + for inverse in tool.Ifc.get().get_inverse(old_profile): + ifcopenshell.util.element.replace_attribute(inverse, old_profile, profile) + tool.Profile.replace_profile_in_profiles_ui(old_profile.id(), profile.id()) + ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_profile) tool.Geometry.reload_representation(props.representation_obj) tool.Geometry.import_item(obj) tool.Geometry.import_item_attributes(obj) element = tool.Ifc.get_entity(props.representation_obj) - # Only certain classes should have a footprint - if element.is_a() in ("IfcSlab", "IfcRamp"): + # Only certain classes should have a footprint. An unchanged parametric profile keeps + # its existing footprint and has no OuterCurve to rebuild one from anyway. + if not profile_unchanged and element.is_a() in ("IfcSlab", "IfcRamp"): footprint_context = ifcopenshell.util.representation.get_context( tool.Ifc.get(), "Plan", "FootPrint", "SKETCH_VIEW" ) @@ -2853,14 +2861,18 @@ class DirectProfileEdit(bpy.types.Operator, tool.Ifc.Operator): return {"CANCELLED"} old_profile = item.SweptArea - profile.ProfileName = old_profile.ProfileName - ifc_file = tool.Ifc.get() - for inverse in ifc_file.get_inverse(old_profile): - ifcopenshell.util.element.replace_attribute(inverse, old_profile, profile) - tool.Profile.replace_profile_in_profiles_ui(old_profile.id(), profile.id()) - ifcopenshell.util.element.remove_deep2(ifc_file, old_profile) + if tool.Model.profile_shape_is_unchanged(old_profile, profile): + # Keep the parametric profile that an edit mode round trip would otherwise + # rewrite as an arbitrary profile (see #6481). + ifcopenshell.util.element.remove_deep2(ifc_file, profile) + else: + profile.ProfileName = old_profile.ProfileName + for inverse in ifc_file.get_inverse(old_profile): + ifcopenshell.util.element.replace_attribute(inverse, old_profile, profile) + tool.Profile.replace_profile_in_profiles_ui(old_profile.id(), profile.id()) + ifcopenshell.util.element.remove_deep2(ifc_file, old_profile) if props.representation_obj: tool.Geometry.reload_representation(props.representation_obj) diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 4f47740e6d..23abca11c7 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -725,9 +725,16 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): return old_profile = extrusion.SweptArea - for inverse in tool.Ifc.get().get_inverse(old_profile): - ifcopenshell.util.element.replace_attribute(inverse, old_profile, profile) - ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_profile) + profile_unchanged = tool.Model.profile_shape_is_unchanged(old_profile, profile) + if profile_unchanged: + # Keep the parametric profile that an edit mode round trip would otherwise + # rewrite as an arbitrary profile (see #6481). + ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), profile) + profile = old_profile + else: + for inverse in tool.Ifc.get().get_inverse(old_profile): + ifcopenshell.util.element.replace_attribute(inverse, old_profile, profile) + ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_profile) bonsai.core.geometry.switch_representation( tool.Ifc, @@ -736,8 +743,9 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): representation=body, ) - # Only certain classes should have a footprint - if element.is_a() not in ("IfcSlab", "IfcRamp"): + # An unchanged profile keeps its existing footprint, and a parametric profile has no + # OuterCurve to rebuild one from anyway. + if profile_unchanged or element.is_a() not in ("IfcSlab", "IfcRamp"): return footprint_context = ifcopenshell.util.representation.get_context( diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index f54633c3cc..76c7018446 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -287,6 +287,56 @@ class Model(bonsai.core.tool.Model): if isinstance(result, dict) and result["profile_def"]: return tool.Ifc.get().add(result["profile_def"]) + @classmethod + def get_profile_region(cls, profile: ifcopenshell.entity_instance) -> Union[shapely.Geometry, None]: + """Return the 2D area enclosed by a profile def as a Shapely geometry, or None. + + Holes are honoured with the same odd even nesting used by auto_detect_profiles so a + hollow profile is never mistaken for its solid outline. + """ + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", W.CURVES_SURFACES_AND_SOLIDS) + try: + shape = ifcopenshell.geom.create_shape(settings, profile) + except RuntimeError: + return None + vertices = np.round(ifcopenshell.util.shape.get_vertices(shape, is_2d=True), 4) + lines = [shapely.LineString([vertices[a], vertices[b]]) for a, b in ifcopenshell.util.shape.get_edges(shape)] + if not lines: + return None + merged = shapely.union_all(lines) + faces = list(shapely.polygonize(list(getattr(merged, "geoms", [merged]))).geoms) + if not faces: + return None + shells = [shapely.Polygon(face.exterior) for face in faces] + region = None + for i, face in enumerate(faces): + depth = sum(1 for j, shell in enumerate(shells) if j != i and shell.contains_properly(face.representative_point())) + if depth % 2: + continue + region = face if region is None else region.union(face) + return region + + @classmethod + def profile_shape_is_unchanged( + cls, old_profile: ifcopenshell.entity_instance, new_profile: ifcopenshell.entity_instance + ) -> bool: + """True when new_profile encloses the same area as an existing parametric old_profile. + + export_profile always rebuilds an edited cross section as an arbitrary profile, so a + parametric type such as IfcRectangleProfileDef is dropped on every edit mode round trip + even when nothing moved (see #6481). Callers use this to keep the parametric old_profile + whenever the outline still matches, and to fall back to new_profile for genuine edits. + """ + if old_profile.is_a("IfcArbitraryClosedProfileDef") or old_profile.is_a("IfcCompositeProfileDef"): + return False + old_region = cls.get_profile_region(old_profile) + new_region = cls.get_profile_region(new_profile) + if old_region is None or new_region is None: + return False + # A tiny area threshold ignores sub 0.1mm vertex rounding while still catching real edits. + return old_region.symmetric_difference(new_region).area < 1e-6 + @classmethod def export_curves( cls, obj: bpy.types.Object, position: Optional[Matrix] = None