See #1227. Reimplement extend walls to slab (or anything with negative Z faces)

There are a few differences to the previous implementation:

 1. It is no longer recalculated on wall regeneration. The new wall
regeneration is strict to the spec on the rules of rel connects path,
and so this being a "userdefined" connection we only calculate it
explicitly when the user invokes the operator.
 2. It uses meshes instead of clipping planes, so you can clip to
strange shapes or gable roofs or whatever. Nice.
This commit is contained in:
Dion Moult
2025-03-12 18:05:51 +11:00
parent a7a4198fc1
commit f5a1c4aae3
8 changed files with 166 additions and 18 deletions
@@ -1241,10 +1241,17 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
# Extend LAYER2s to LAYER3
[o.select_set(False) for o in selected_usages.get("PROFILE", [])]
[o.select_set(False) for o in selected_usages.get("LAYER3", []) if o != bpy.context.active_object]
try:
core.join_walls_TZ(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model)
except core.RequireAtLeastTwoLayeredElements as e:
self.report({"ERROR"}, str(e))
slab = None
walls = []
if (obj := tool.Blender.get_active_object(is_selected=True)) and (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER3":
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":
walls.append(obj)
if slab and walls:
core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slab, walls)
else:
self.report({"ERROR"}, "Please select at least one LAYER2 element and an active LAYER3 element")
elif self.active_material_usage == "LAYER2":
# Extend LAYER2s to LAYER2
+16 -1
View File
@@ -17,7 +17,7 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bonsai.core.tool as tool
from typing import Literal
from typing import Literal, Iterable
def unjoin_walls(ifc: tool.Ifc, blender: tool.Blender, geometry: tool.Geometry, joiner, model: tool.Model) -> None:
@@ -63,6 +63,21 @@ def join_walls_LV(
joiner.connect(another_selected_object, active_obj)
def extend_wall_to_slab(
ifc: tool.Ifc, geometry: tool.Geometry, model: tool.Model, slab_obj, wall_objs: Iterable
) -> None:
if not (clip := model.get_slab_clipping_bmesh(slab_obj)):
return # Nothing to clip?
slab = ifc.get_entity(slab_obj)
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)
def join_walls_TZ(ifc: tool.Ifc, blender: tool.Blender, geometry: tool.Geometry, joiner, model: tool.Model) -> None:
selected_objs = [
o
+10 -5
View File
@@ -432,6 +432,7 @@ class Geometry:
def rename_object(cls, obj, name): pass
def replace_object_data_globally(cls, old_data, new_data): pass
def resolve_mapped_representation(cls, representation): pass
def run_edit_object_placement(cls, obj=None): pass
def run_geometry_update_representation(cls, obj=None): pass
def run_style_add_style(cls, obj=None): pass
def select_connection(cls, connection): pass
@@ -562,22 +563,26 @@ class Misc:
@interface
class Model:
def clip_wall_to_slab(cls, element, bm): 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
def export_points(cls, position, indices): pass
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 import_profile(cls, profile, obj=None, position=None): pass
def get_manual_booleans(cls, element): pass
def get_material_layer_parameters(cls, element): pass
def get_slab_clipping_bmesh(cls, obj): pass
def get_usage_type(cls, element): pass
def get_wall_axis(cls, obj, layers=None): pass
def import_curve(cls, curve, obj=None, position=None): pass
def import_profile(cls, profile, obj=None, position=None): pass
def import_rectangle(cls, obj, position, profile): pass
def load_openings(cls, openings): pass
def purge_scene_openings(cls): pass
def get_usage_type(cls, element): pass
def get_material_layer_parameters(cls, element): pass
def get_manual_booleans(cls, element): pass
def get_wall_axis(cls, obj, layers=None): pass
def regenerate_array(cls, parent, data): pass
def reload_body_representation(cls, obj_or_objects): pass
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
+13 -6
View File
@@ -154,15 +154,22 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def get_active_object(cls, is_selected: bool = False) -> Union[bpy.types.Object, None]:
obj = getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active
if not is_selected:
return obj
if obj in cls.get_selected_objects(include_active=False):
return obj
"""Gets the active object
:param is_selected: If true, the active object also needs to be selected.
"""
if obj := (getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active):
if not is_selected:
return obj
if obj.select_get():
return obj
@classmethod
def get_selected_objects(cls, include_active: bool = True) -> set[bpy.types.Object]:
"""Get selected objects including active object."""
"""Get selected objects
:param include_active: If true, the active object is included regardless if it is also selected.
"""
if selected_objects := getattr(bpy.context, "selected_objects", None):
if include_active and (active_obj := cls.get_active_object()):
return set(selected_objects + [active_obj])
+10
View File
@@ -63,6 +63,16 @@ class Debug(bonsai.core.tool.Debug):
except PermissionError:
pass
@classmethod
def debug_bmesh(
cls, bm: bpy.types.BMesh, name: str = "Debug"
) -> bpy.types.Object:
mesh = bpy.data.meshes.new("Debug")
bm.to_mesh(mesh)
obj = bpy.data.objects.new(name, mesh)
bpy.context.scene.collection.objects.link(obj)
return obj
@classmethod
def debug_geometry(
cls, verts: list[Vector] = [], edges: list[tuple[int, int]] = [], name: str = "Debug"
+4
View File
@@ -1986,3 +1986,7 @@ class Geometry(bonsai.core.tool.Geometry):
bm = tool.Blender.get_bmesh_for_mesh(obj.data)
bm.transform(obj.matrix_world)
return BVHTree.FromBMesh(bm)
@classmethod
def run_edit_object_placement(cls, obj: bpy.types.Object) -> None:
return bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
+82 -2
View File
@@ -39,7 +39,7 @@ import bonsai.core.geometry
import bonsai.core.tool
import bonsai.tool as tool
import bonsai.core.geometry as geometry
from math import atan, cos, degrees, radians, pi
from math import atan, cos, degrees, pi, inf
from mathutils import Matrix, Vector
from copy import deepcopy
from functools import partial
@@ -284,7 +284,7 @@ class Model(bonsai.core.tool.Model):
@classmethod
def get_extrusion(cls, representation: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""return first found IfcExtrudedAreaSolid"""
"""Return first found IfcExtrudedAreaSolid"""
item = representation.Items[0]
while True:
if item.is_a("IfcExtrudedAreaSolid"):
@@ -2101,3 +2101,83 @@ class Model(bonsai.core.tool.Model):
return
op = layout.operator("bim.material_ui_select", icon="ZOOM_SELECTED", text="")
op.material_id = material_id_int
@classmethod
def get_slab_clipping_bmesh(cls, obj: bpy.types.Object) -> bpy.types.BMesh | None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.faces.ensure_lookup_table()
clipping_bm = bmesh.new()
vertex_map = {}
for face in bm.faces:
face.normal_update()
normal = face.normal.to_4d()
normal.w = 0
if (obj.matrix_world @ normal).z >= 0:
continue
new_verts = []
for vert in face.verts:
if not (new_vert := vertex_map.get(vert.index, None)):
new_vert = clipping_bm.verts.new(obj.matrix_world @ vert.co / unit_scale)
vertex_map[vert.index] = new_vert
new_verts.append(new_vert)
clipping_bm.faces.new(new_verts)
if not len(clipping_bm.faces):
return
return clipping_bm # clipping_bm is in project units
@classmethod
def clip_wall_to_slab(cls, wall: ifcopenshell.entity_instance, clipping_bm: bpy.types.BMesh) -> 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)
bm.verts.ensure_lookup_table()
zs = [v.co.z for v in bm.verts]
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())
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))
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)
for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []:
if extrusion.Position:
position = ifcopenshell.util.placement.get_axis2placement(extrusion.Position)
else:
position = np.eye(4)
direction = np.array(extrusion.ExtrudedDirection[0])
direction /= np.linalg.norm(direction)
direction = position @ np.append(direction, 0.0)
if direction[2] <= 0 or position[2][3] > max_z:
continue
extrusion.Depth = max_z / direction[2]
if operand:
booleans = ifcopenshell.api.geometry.add_boolean(
tool.Ifc.get(), first_item=extrusion, second_items=[operand]
)
tool.Model.mark_manual_booleans(wall, booleans)
@classmethod
def connect_wall_to_slab(cls, wall: ifcopenshell.entity_instance, slab: ifcopenshell.entity_instance) -> None:
ifcopenshell.api.geometry.connect_element(
tool.Ifc.get(), relating_element=slab, related_element=wall, description="TOP"
)
@@ -721,6 +721,26 @@ def get_extrusions(element: ifcopenshell.entity_instance) -> Union[list[ifcopens
return extrusions
def get_base_extrusions(element: ifcopenshell.entity_instance) -> Union[list[ifcopenshell.entity_instance], None]:
"""Gets all base extrusions used to define an element's model body geometry
A base extrusion is assumed to be an extrusion prior to all boolean
results.
:param element: The element occurrence
:return: A list of extrusion representation items or `None` if element has no representation.
"""
if not (rep := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")):
return
extrusions = []
for item in ifcopenshell.util.representation.resolve_representation(rep).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
extrusions.append(item)
return extrusions
def get_total_edge_length(geometry: ShapeType) -> float:
"""Calculates the total length of edges in a given geometry.