Bonsai: preserve a parametric profile through an edit round trip (#6481)

Tabbing into an extrusion's profile and back out (Object -> Item -> Edit ->
Item -> Object) with no edits silently rewrote a parametric profile such as
IfcRectangleProfileDef into an IfcArbitraryClosedProfileDef. Exiting profile
edit always rebuilds the SweptArea from the Blender mesh via
auto_detect_profiles, which only ever emits arbitrary profiles and has no
dirty check, so the parametric type was destroyed on every round trip.

Add tool.Model.profile_shape_is_unchanged: it builds the 2D region of the
old and freshly re-derived profiles (via ifcopenshell.geom + shapely, holes
honoured with odd/even nesting) and compares them by symmetric difference
area (< 1e-6). This is a true geometric comparison, not a scalar area check,
so two different shapes that happen to share an area are correctly treated
as changed. The three profile-edit exit sites (OverrideModeSetObject item
round trip, DirectProfileEdit, and slab EditExtrusionProfile) now keep the
original parametric profile when the outline is unchanged, discard the
rebuilt arbitrary one, and skip regenerating the slab/ramp footprint; a
genuine edit still rebuilds the arbitrary profile as before.

Verified live in headless Blender: a 0.5x0.5 IfcRectangleProfileDef survives
the no-op Tab cycle as IfcRectangleProfileDef (was IfcArbitraryClosedProfileDef),
and moving a vertex mid-edit still regenerates an arbitrary profile.
Independently validated the comparison: a rectangle vs the same square gives
symmetric difference 0.0 (kept), vs a same-area 0.125x2.0 rectangle gives
0.375 (replaced). Core test_geometry.py + test_model.py: 14 + 36 passed.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Petru Conduraru
2026-07-12 19:13:40 +03:00
parent 980988f208
commit e1c0e7bfcf
3 changed files with 88 additions and 18 deletions
@@ -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)
+13 -5
View File
@@ -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(
+50
View File
@@ -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