fix #8146: Remove slab connection when clip FaceSet is deleted

When a user deletes an IfcPolygonalFaceSet item in item mode,
remove the corresponding IfcRelConnectsElements(TOP) so that
regenerate_wall_to_underside no longer finds the slab connected
and does not re-create the boolean.

BBIM_Boolean pset data is migrated from a flat list of boolean IDs
to a dict mapping str(boolean_id) → slab GlobalId. This enables
per-slab disconnect: deleting a FaceSet for slab A removes only
slab A's connection, leaving slab B's boolean and connection intact.
Legacy pset files (no slab guid) fall back to unmarking only the
matched boolean and leaving connections untouched.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Ryan Schultz
2026-06-04 17:42:26 -05:00
parent 1f2b20fd86
commit b6e4b5b50e
4 changed files with 102 additions and 20 deletions
+2 -2
View File
@@ -183,7 +183,7 @@ def regenerate_wall_to_underside(
for slab_obj in slab_objs:
clip = model.get_slab_clipping_bmesh(slab_obj)
if clip:
model.clip_wall_to_slab(wall, clip)
model.clip_wall_to_slab(wall, clip, ifc.get_entity(slab_obj))
clipped_objs.append(obj)
if clipped_objs:
model.reload_body_representation(clipped_objs)
@@ -221,7 +221,7 @@ def extend_wall_to_slab(
clip = model.get_slab_clipping_bmesh(slab_obj)
if not clip:
continue
model.clip_wall_to_slab(wall, clip)
model.clip_wall_to_slab(wall, clip, ifc.get_entity(slab_obj))
model.connect_wall_to_slab(wall, ifc.get_entity(slab_obj))
did_clip = True
if did_clip:
+1 -1
View File
@@ -673,7 +673,7 @@ class Misc:
@interface
class Model:
def clip_wall_to_slab(cls, element, bm): pass
def clip_wall_to_slab(cls, element, bm, slab=None): pass
def connect_wall_to_slab(cls, wall, slab): pass
def convert_si_to_unit(cls, value): pass
def convert_unit_to_si(cls, value): pass
+2
View File
@@ -266,6 +266,8 @@ class Geometry(bonsai.core.tool.Geometry):
return
rep_obj = props.representation_obj
assert (rep_obj := props.representation_obj) and (rep_element := tool.Ifc.get_entity(rep_obj))
if item.is_a("IfcTessellatedFaceSet"):
tool.Model.disconnect_slab_for_deleted_boolean(rep_element, item)
cls.remove_representation_item(item, rep_element)
cls.reload_representation(props.representation_obj)
bpy.data.objects.remove(obj)
+97 -17
View File
@@ -87,6 +87,17 @@ if TYPE_CHECKING:
)
def _boolean_has_faceset(
ifc_file: ifcopenshell.file,
bool_id: int,
faceset: ifcopenshell.entity_instance,
) -> bool:
try:
return ifc_file.by_id(bool_id).SecondOperand == faceset
except RuntimeError:
return False
class Model(bonsai.core.tool.Model):
@classmethod
def get_model_props(cls) -> BIMModelProperties:
@@ -903,29 +914,41 @@ class Model(bonsai.core.tool.Model):
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
if not pset:
return []
boolean_ids = json.loads(pset["Data"])
raw = json.loads(pset["Data"])
# Data is either a legacy list of int IDs or a dict mapping str(id) -> slab_guid.
boolean_ids = set(raw) if isinstance(raw, list) else {int(k) for k in raw}
if representation is None:
representation = tool.Geometry.get_body_representation(element)
if not representation:
return []
all_chain_booleans = cls.get_booleans(element, representation)
booleans = [b for b in all_chain_booleans if b.id() in boolean_ids]
return booleans
return [b for b in all_chain_booleans if b.id() in boolean_ids]
@classmethod
def mark_manual_booleans(
cls, element: ifcopenshell.entity_instance, booleans: list[ifcopenshell.entity_instance]
cls,
element: ifcopenshell.entity_instance,
booleans: list[ifcopenshell.entity_instance],
slab: Optional[ifcopenshell.entity_instance] = None,
) -> None:
"""Track ``booleans`` in the BBIM_Boolean pset, optionally associating each with ``slab``.
Data is stored as a dict mapping str(boolean_id) slab GlobalId (or None for legacy).
"""
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
boolean_ids = [b.id() for b in booleans]
slab_guid = slab.GlobalId if slab else None
new_entries = {str(b.id()): slab_guid for b in booleans}
if pset_data:
pset = tool.Ifc.get().by_id(pset_data["id"])
data = json.loads(pset_data["Data"])
data.extend(boolean_ids)
data = list(set(data))
raw = json.loads(pset_data["Data"])
# Migrate legacy list format in place.
if isinstance(raw, list):
raw = {str(i): None for i in raw}
raw.update(new_entries)
data = raw
else:
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Boolean")
data = boolean_ids
data = new_entries
data = tool.Ifc.get().createIfcText(json.dumps(data))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
@@ -940,12 +963,14 @@ class Model(bonsai.core.tool.Model):
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
if not pset:
return
data = set(json.loads(pset["Data"]))
data -= set(boolean_ids)
data = list(data)
raw = json.loads(pset["Data"])
if isinstance(raw, list):
raw = {str(i): None for i in raw}
for bool_id in boolean_ids:
raw.pop(str(bool_id), None)
pset = tool.Ifc.get().by_id(pset["id"])
if data:
data = tool.Ifc.get().createIfcText(json.dumps(data))
if raw:
data = tool.Ifc.get().createIfcText(json.dumps(raw))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
else:
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
@@ -2636,7 +2661,12 @@ class Model(bonsai.core.tool.Model):
return clipping_bm # clipping_bm is in project units
@classmethod
def clip_wall_to_slab(cls, wall: ifcopenshell.entity_instance, clipping_bm: bmesh.types.BMesh) -> None:
def clip_wall_to_slab(
cls,
wall: ifcopenshell.entity_instance,
clipping_bm: bmesh.types.BMesh,
slab: Optional[ifcopenshell.entity_instance] = None,
) -> None:
matrix_i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(wall.ObjectPlacement))
bm = clipping_bm.copy()
bmesh.ops.transform(bm, matrix=Matrix(matrix_i.tolist()), verts=bm.verts)
@@ -2710,9 +2740,8 @@ class Model(bonsai.core.tool.Model):
extrusion.Depth = max_z / direction[2]
if operands:
body_repr = ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW")
booleans = ifcopenshell.api.geometry.add_boolean(ifc_file, first_item=extrusion, second_items=operands)
tool.Model.mark_manual_booleans(wall, booleans)
tool.Model.mark_manual_booleans(wall, booleans, slab=slab)
@classmethod
def connect_wall_to_slab(cls, wall: ifcopenshell.entity_instance, slab: ifcopenshell.entity_instance) -> None:
@@ -2720,6 +2749,57 @@ class Model(bonsai.core.tool.Model):
tool.Ifc.get(), relating_element=slab, related_element=wall, description="TOP"
)
@classmethod
def disconnect_slab_for_deleted_boolean(
cls, wall: ifcopenshell.entity_instance, faceset: ifcopenshell.entity_instance
) -> None:
"""Remove IfcRelConnectsElements(TOP) for wall when a clip FaceSet is manually deleted.
When the user deletes an IfcPolygonalFaceSet item in item mode, the corresponding
IfcBooleanResult becomes orphaned (SecondOperand None). Without removing the
IfcRelConnectsElements, regenerate_wall_to_underside would still find the slab as
connected and re-create the clip boolean. This method removes the slab connection
when all tracked booleans for this wall are gone.
"""
pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
if not pset:
return
ifc_file = tool.Ifc.get()
raw = json.loads(pset["Data"])
if isinstance(raw, list):
raw = {str(i): None for i in raw}
# Find the tracked boolean whose SecondOperand is this FaceSet, and its slab guid.
matched_slab_guid = None
found = False
for bool_id_str, slab_guid in raw.items():
try:
b = ifc_file.by_id(int(bool_id_str))
except RuntimeError:
continue
if b.SecondOperand == faceset:
matched_slab_guid = slab_guid
found = True
break
if not found:
return
if matched_slab_guid is None:
# Legacy pset (no slab guid stored) — we can only unmark the one boolean we matched.
# We cannot safely identify which other booleans or connections belong to the same
# slab, so we leave them intact. Recreating the booleans via the operator will
# upgrade the pset to the new format and enable full per-slab tracking.
matched_bool_id = next(int(k) for k, v in raw.items() if v is None and _boolean_has_faceset(ifc_file, int(k), faceset))
cls.unmark_manual_booleans(wall, [matched_bool_id])
return
# Unmark ALL booleans that belong to the same slab (they share the same guid).
slab_bool_ids = [int(k) for k, v in raw.items() if v == matched_slab_guid]
cls.unmark_manual_booleans(wall, slab_bool_ids)
# Remove only the IfcRelConnectsElements for this specific slab.
for rel in list(wall.ConnectedFrom):
if not (rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP"):
continue
if rel.RelatingElement.GlobalId == matched_slab_guid:
ifc_file.remove(rel)
@classmethod
def get_epg_modifier(cls, obj: bpy.types.Object) -> Union[bpy.types.NodesModifier, None]:
for m in obj.modifiers: