Compare commits

...

7 Commits

Author SHA1 Message Date
falken10vdl bfb4312906 Add has_underside_connection method to Model class and update wall regeneration logic 2026-06-01 07:19:26 -05:00
Ryan Schultz d6662e5c56 Fix validate_type corruption; remove debug prints
When validate_type selected a preferred_item from remaining_items
(e.g. the sole IfcBooleanResult in a representation), it left that
item in the list. The subsequent Items filter removed every item,
leaving Items=[] and causing guess_type to return
"MappedRepresentation" — silently corrupting the representation.

Also removes temporary debug print statements added during
investigation of the wall-to-slab extension workflow.

Generated with the assistance of an AI coding tool.
2026-06-01 07:19:26 -05:00
Ryan Schultz 4475111d0c Fix duplicate booleans in extend_walls_to_underside
Re-running the operator on the same wall/slab pair created
additional IfcPolygonalFaceSet booleans each time. Now each
wall's existing booleans are removed before re-clipping, and
previously connected slabs are merged with the new selection
so no earlier clips are silently discarded.

Generated with the assistance of an AI coding tool.
2026-06-01 07:19:26 -05:00
Ryan Schultz acd62c59d0 Regenerate connected walls when recalculating a slab
When Shift+G is pressed on a LAYER3 element, any LAYER2 walls
connected via IfcRelConnectsElements(TOP) are now re-clipped
to the slab's updated geometry after recalculate_slab runs.

Generated with the assistance of an AI coding tool.
2026-06-01 07:19:26 -05:00
Ryan Schultz 2e85a11c82 Add extend/regenerate walls to multiple undersides
extend_walls_to_underside now accepts multiple slab/roof
objects in a single operation — all selected non-LAYER2 IFC
elements are treated as clip targets, all LAYER2 elements as
walls. Placement sync is done once upfront; each wall is then
clipped against every selected slab before reloading.

Also adds bim.regenerate_wall_to_underside (Shift+G): after
moving a slab, re-clips connected walls using the existing
IfcRelConnectsElements(TOP) relationship. Old booleans are
removed via remove_representation_item before re-clipping.

Generated with the assistance of an AI coding tool.
2026-06-01 07:19:26 -05:00
Ryan Schultz 5217ff0dce Closes #7943: Add regenerate_wall_to_underside operator
When extend_walls_to_underside is applied to a wall and the
roof/slab is later moved, pressing Shift+G now re-clips the
wall to the slab's new position.

The IFC relationship created by connect_wall_to_slab
(IfcRelConnectsElements, Description="TOP") is used to look
up which slabs a wall is clipped to. On regeneration, the
existing manual booleans (IfcPolygonalFaceSet operands) are
cleanly removed via remove_representation_item, then
clip_wall_to_slab is re-applied for each connected slab.

Shift+G on a LAYER2 wall that has a TOP connection now calls
bim.regenerate_wall_to_underside; walls without a connection
continue to call bim.recalculate_wall as before.

Generated with the assistance of an AI coding tool.
2026-06-01 07:16:09 -05:00
Ryan Schultz 94aaa5a6b2 Fix extend_walls_to_underside ridge artifact
When the operator was called twice on the same wall for a
ridge roof, the two IfcPolygonalFaceSet clip solids shared
an exact ridge edge (kissing-solid). OCCT produced spurious
extra vertices at the coincident boundary.

Fix by building the clip solid from a rectangle on the slope
plane that extends slightly past the face edge (1 project
unit margin) rather than the exact face footprint. Adjacent
slope solids now volumetrically overlap at the ridge instead
of sharing a boundary face, which OCCT handles correctly.

Generated with the assistance of an AI coding tool.
2026-06-01 07:16:08 -05:00
8 changed files with 232 additions and 37 deletions
@@ -85,6 +85,7 @@ classes = (
wall.EnableEditingWall,
wall.ExtendWallHeightToCursor,
wall.ExtendWallsToUnderside,
wall.RegenerateWallToUnderside,
wall.ExtendWallsToWall,
wall.ExtendWallsToPolylinePoint,
wall.ExtendWallToCursor,
+29 -8
View File
@@ -300,18 +300,39 @@ class ExtendWallsToUnderside(bpy.types.Operator, tool.Ifc.Operator):
# of the selected walls has an in-progress parametric draft, commit it before
# extending, so the slab clip operates on the just-finalised IFC state.
_commit_pending_wall_edits_for_selection(context)
slab = None
slabs: list[bpy.types.Object] = []
walls: list[bpy.types.Object] = []
if (obj := tool.Blender.get_active_object(is_selected=True)) and (element := tool.Ifc.get_entity(obj)):
slab = obj
for obj in tool.Blender.get_selected_objects(include_active=False):
if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2":
for obj in tool.Blender.get_selected_objects():
element = tool.Ifc.get_entity(obj)
if not element:
continue
if tool.Model.get_usage_type(element) == "LAYER2":
walls.append(obj)
if slab and walls:
core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slab, walls)
else:
slabs.append(obj)
if slabs and walls:
core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slabs, walls)
_resync_walls_after_mutation(walls)
else:
self.report({"ERROR"}, "Please select at least one LAYER2 element and an active element")
self.report({"ERROR"}, "Please select at least one LAYER2 element and at least one other IFC element")
class RegenerateWallToUnderside(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.regenerate_wall_to_underside"
bl_label = "Regenerate Wall to Underside"
bl_description = "Re-clip selected walls to their connected underside objects after the slab has moved"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
wall_objs = [
obj
for obj in tool.Blender.get_selected_objects()
if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2"
]
if wall_objs:
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
else:
self.report({"ERROR"}, "Please select at least one LAYER2 element")
class ExtendWallsToWall(bpy.types.Operator, tool.Ifc.Operator):
@@ -1294,9 +1294,15 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
bpy.ops.bim.generate_space()
return
if self.active_material_usage == "LAYER2":
bpy.ops.bim.recalculate_wall()
if element and tool.Model.has_underside_connection(element):
bpy.ops.bim.regenerate_wall_to_underside()
else:
bpy.ops.bim.recalculate_wall()
elif self.active_material_usage == "LAYER3":
bpy.ops.bim.recalculate_slab()
wall_objs = tool.Model.get_connected_wall_objs(element)
if wall_objs:
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
elif tool.System.get_ports(element):
bpy.ops.bim.regenerate_distribution_element()
elif self.active_material_usage == "PROFILE":
+57 -7
View File
@@ -161,23 +161,73 @@ def align_objects(
model.align_objects(reference_obj, objs, align_type)
def regenerate_wall_to_underside(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
model: type[tool.Model],
wall_objs: list[bpy.types.Object],
) -> None:
"""Re-clip walls to their connected underside objects after the slab has moved."""
clipped_objs = []
for obj in wall_objs:
wall = ifc.get_entity(obj)
slab_objs = model.get_connected_slab_objs(wall)
if not slab_objs:
continue
if ifc.is_moved(obj):
geometry.run_edit_object_placement(obj=obj)
# Sync each slab's Blender mesh to its current IFC representation before
# reading face geometry, so a changed profile is picked up correctly.
model.reload_body_representation(slab_objs)
model.remove_wall_to_underside_booleans(wall)
for slab_obj in slab_objs:
clip = model.get_slab_clipping_bmesh(slab_obj)
if clip:
model.clip_wall_to_slab(wall, clip)
clipped_objs.append(obj)
if clipped_objs:
model.reload_body_representation(clipped_objs)
def extend_wall_to_slab(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
model: type[tool.Model],
slab_obj: bpy.types.Object,
slab_objs: list[bpy.types.Object],
wall_objs: list[bpy.types.Object],
) -> None:
if not (clip := model.get_slab_clipping_bmesh(slab_obj)):
return # Nothing to clip?
slab = ifc.get_entity(slab_obj)
# If any wall is currently in item mode, exit it before modifying the
# representation. Leaving stale item objects around causes delete_ifc_item
# to later remove the extrusion (or other pre-boolean items) from inside
# the boolean chain, corrupting the IFC model.
geom_props = geometry.get_geometry_props()
if geom_props.representation_obj in wall_objs:
geometry.disable_item_mode()
clipped_walls = []
for obj in wall_objs:
if ifc.is_moved(obj):
geometry.run_edit_object_placement(obj=obj)
wall = ifc.get_entity(obj)
model.clip_wall_to_slab(wall, clip)
model.connect_wall_to_slab(wall, slab)
model.reload_body_representation(wall_objs)
# Merge previously connected slabs with newly requested ones so that
# re-running the operator never produces duplicate booleans and never
# silently discards clips that were applied in an earlier call.
existing = model.get_connected_slab_objs(wall)
seen = {id(s) for s in existing}
all_slab_objs = list(existing) + [s for s in slab_objs if id(s) not in seen]
# Remove stale booleans once, then re-clip against the full set.
model.remove_wall_to_underside_booleans(wall)
did_clip = False
for slab_obj in all_slab_objs:
clip = model.get_slab_clipping_bmesh(slab_obj)
if not clip:
continue
model.clip_wall_to_slab(wall, clip)
model.connect_wall_to_slab(wall, ifc.get_entity(slab_obj))
did_clip = True
if did_clip:
clipped_walls.append(obj)
if clipped_walls:
model.reload_body_representation(clipped_walls)
class RequireTwoWallsError(Exception):
+4
View File
@@ -681,6 +681,9 @@ class Model:
def export_profile(cls, obj, position=None): pass
def generate_occurrence_name(cls, element_type, ifc_class): pass
def get_extrusion(cls, representation): pass
def get_connected_slab_objs(cls, wall): pass
def get_connected_wall_objs(cls, slab): pass
def has_underside_connection(cls, element): pass
def get_manual_booleans(cls, element): pass
def get_material_layer_parameters(cls, element): pass
def get_slab_clipping_bmesh(cls, obj): pass
@@ -696,6 +699,7 @@ class Model:
def regenerate_profile(cls, obj): pass
def regenerate_slab(cls, obj): pass
def reload_body_representation(cls, obj_or_objects): pass
def remove_wall_to_underside_booleans(cls, wall): pass
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
+19 -6
View File
@@ -257,7 +257,13 @@ class Geometry(bonsai.core.tool.Geometry):
break
mesh = obj.data
assert isinstance(mesh, bpy.types.Mesh)
item = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id)
item_id = tool.Geometry.get_mesh_props(mesh).ifc_definition_id
try:
item = tool.Ifc.get().by_id(item_id)
except RuntimeError:
# Entity already deleted (e.g. removed as part of a sibling boolean collapse).
bpy.data.objects.remove(obj)
return
rep_obj = props.representation_obj
assert (rep_obj := props.representation_obj) and (rep_element := tool.Ifc.get_entity(rep_obj))
cls.remove_representation_item(item, rep_element)
@@ -1157,11 +1163,16 @@ class Geometry(bonsai.core.tool.Geometry):
@classmethod
def get_representation_item(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
data = obj.data
if (
isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES)
and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id)
and ((item := tool.Ifc.get().by_id(ifc_id)).is_a("IfcRepresentationItem"))
):
if not isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES):
return None
ifc_id = tool.Geometry.get_mesh_props(data).ifc_definition_id
if not ifc_id:
return None
try:
item = tool.Ifc.get().by_id(ifc_id)
except RuntimeError:
return None
if item.is_a("IfcRepresentationItem"):
return item
return None
@@ -1335,6 +1346,8 @@ class Geometry(bonsai.core.tool.Geometry):
cls, representation: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance:
if representation.RepresentationType == "MappedRepresentation":
if not representation.Items:
return representation
return cls.resolve_mapped_representation(representation.Items[0].MappingSource.MappedRepresentation)
return representation
+108 -15
View File
@@ -351,6 +351,8 @@ class Model(bonsai.core.tool.Model):
@classmethod
def get_extrusion(cls, representation: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""Return first found IfcExtrudedAreaSolid"""
if not representation.Items:
return None
item = representation.Items[0]
while True:
if item.is_a("IfcExtrudedAreaSolid"):
@@ -843,6 +845,57 @@ class Model(bonsai.core.tool.Model):
items.append(item.FirstOperand)
return booleans
@classmethod
def get_connected_slab_objs(cls, wall: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
"""Return Blender objects for slabs connected to wall via IfcRelConnectsElements(TOP)."""
result = []
for rel in wall.ConnectedFrom:
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
slab_obj = tool.Ifc.get_object(rel.RelatingElement)
if slab_obj:
result.append(slab_obj)
return result
@classmethod
def get_connected_wall_objs(cls, slab: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
"""Return Blender objects for LAYER2 walls connected to slab via IfcRelConnectsElements(TOP)."""
result = []
for rel in slab.ConnectedTo:
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
wall_obj = tool.Ifc.get_object(rel.RelatedElement)
if wall_obj:
result.append(wall_obj)
return result
@classmethod
def has_underside_connection(cls, element: ifcopenshell.entity_instance) -> bool:
"""Return True if element has an IfcRelConnectsElements(TOP) relationship."""
return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom)
@classmethod
def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None:
"""Remove all IfcBooleanResult items previously added by extend_walls_to_underside."""
manual_booleans = cls.get_manual_booleans(wall)
if not manual_booleans:
return
ifc_file = tool.Ifc.get()
for b in manual_booleans:
sec = b.SecondOperand
if sec is None:
# The IfcPolygonalFaceSet was already deleted externally. Splice the
# orphaned IfcBooleanResult out of the chain so the representation stays valid.
parents = list(ifc_file.get_inverse(b))
for parent in parents:
if parent.is_a("IfcBooleanResult") and parent.FirstOperand == b:
parent.FirstOperand = b.FirstOperand
elif parent.is_a("IfcShapeRepresentation"):
new_items = tuple((set(parent.Items) - {b}) | {b.FirstOperand})
parent.Items = new_items
cls.unmark_manual_booleans(wall, [b.id()])
ifc_file.remove(b)
elif sec.is_a("IfcTessellatedFaceSet"):
tool.Geometry.remove_representation_item(sec, wall)
@classmethod
def get_manual_booleans(
cls, element: ifcopenshell.entity_instance, representation: Optional[ifcopenshell.entity_instance] = None
@@ -855,7 +908,8 @@ class Model(bonsai.core.tool.Model):
representation = tool.Geometry.get_body_representation(element)
if not representation:
return []
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
all_chain_booleans = cls.get_booleans(element, representation)
booleans = [b for b in all_chain_booleans if b.id() in boolean_ids]
return booleans
@classmethod
@@ -2557,12 +2611,15 @@ class Model(bonsai.core.tool.Model):
clipping_bm = bmesh.new()
vertex_map = {}
kept = 0
for face in bm.faces:
face.normal_update()
normal = face.normal.to_4d()
normal.w = 0
if (obj.matrix_world @ normal).z >= -0.5:
world_normal_z = (obj.matrix_world @ normal).z
if world_normal_z >= -0.5:
continue
kept += 1
new_verts = []
for vert in face.verts:
if not (new_vert := vertex_map.get(vert.index, None)):
@@ -2575,6 +2632,7 @@ class Model(bonsai.core.tool.Model):
return
bmesh.ops.recalc_face_normals(clipping_bm, faces=clipping_bm.faces)
clipping_bm.faces.ensure_lookup_table()
return clipping_bm # clipping_bm is in project units
@classmethod
@@ -2588,17 +2646,53 @@ class Model(bonsai.core.tool.Model):
min_z = min(zs)
max_z = max(zs)
operand = None
if (z := max_z - min_z) and not np.isclose(z, 0.0):
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
ifc_file = tool.Ifc.get()
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
result = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
extruded_verts = [elem for elem in result["geom"] if isinstance(elem, bmesh.types.BMVert)]
bmesh.ops.translate(bm, verts=extruded_verts, vec=(0, 0, z))
# Build one IfcPolygonalFaceSet clip solid per clipping face.
# Each solid uses a rectangle on the slope plane rather than the exact face
# footprint. The original approach (exact footprint) caused a kissing-solid /
# boundary-coincidence bug when the operator is called twice for a ridge roof: the
# two slope solids share an exact ridge edge, and OCCT produces spurious extra
# vertices. Extending each solid slightly past the ridge (by margin) creates a
# volumetric overlap instead of a kissing boundary — OCCT handles overlapping
# DIFFERENCE operands correctly.
margin = 1.0 # project units past the face edge — enough to ensure overlap at ridge
operands = []
for face in bm.faces:
face.normal_update()
normal = Vector(face.normal).normalized()
verts = [v.co for v in bm.verts]
faces = [[v.index for v in p.verts] for p in bm.faces]
operand = builder.mesh(verts, faces)
# Orthonormal basis spanning the slope plane.
ref = Vector((0, 0, 1)) if abs(normal.z) < 0.9 else Vector((1, 0, 0))
tangent1 = normal.cross(ref).normalized()
tangent2 = normal.cross(tangent1).normalized()
centroid = sum((v.co for v in face.verts), Vector()) / len(face.verts)
# Tight bounding rectangle in slope-plane coords, plus a small margin.
t1_coords = [(v.co - centroid).dot(tangent1) for v in face.verts]
t2_coords = [(v.co - centroid).dot(tangent2) for v in face.verts]
half1 = max(abs(c) for c in t1_coords) + margin
half2 = max(abs(c) for c in t2_coords) + margin
# Rectangle on the slope plane, extruded upward in wall-local Z.
clip_bm = bmesh.new()
v0 = clip_bm.verts.new(centroid + half1 * tangent1 + half2 * tangent2)
v1 = clip_bm.verts.new(centroid - half1 * tangent1 + half2 * tangent2)
v2 = clip_bm.verts.new(centroid - half1 * tangent1 - half2 * tangent2)
v3 = clip_bm.verts.new(centroid + half1 * tangent1 - half2 * tangent2)
bottom_face = clip_bm.faces.new([v0, v1, v2, v3])
result = bmesh.ops.extrude_face_region(clip_bm, geom=[bottom_face])
top_verts = [e for e in result["geom"] if isinstance(e, bmesh.types.BMVert)]
bmesh.ops.translate(clip_bm, verts=top_verts, vec=Vector((0, 0, max_z - min_z)))
clip_bm.verts.ensure_lookup_table()
clip_verts = [v.co for v in clip_bm.verts]
clip_faces = [[v.index for v in f.verts] for f in clip_bm.faces]
operand = builder.mesh(clip_verts, clip_faces)
clip_bm.free()
operands.append(operand)
for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []:
if extrusion.Position:
@@ -2615,10 +2709,9 @@ class Model(bonsai.core.tool.Model):
extrusion.Depth = max_z / direction[2]
if operand:
booleans = ifcopenshell.api.geometry.add_boolean(
tool.Ifc.get(), first_item=extrusion, second_items=[operand]
)
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)
@classmethod
@@ -81,6 +81,13 @@ def validate_type(
if not preferred_item and remaining_items:
preferred_item = remaining_items[0]
# preferred_item must not appear in remaining_items — if it was selected from
# that list, leaving it in causes add_boolean to union it with itself, and the
# subsequent Items filter then removes ALL items (including preferred_item),
# leaving Items=[] which guess_type maps to "MappedRepresentation".
if preferred_item in remaining_items:
remaining_items = [i for i in remaining_items if i != preferred_item]
if remaining_items:
ifcopenshell.api.geometry.add_boolean(file, preferred_item, remaining_items, "UNION")
representation.Items = [i for i in representation.Items if i not in remaining_items]