mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user