diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 14beafaee7..fc35945aea 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -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 diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index d04486ed1c..897b1af632 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -17,7 +17,7 @@ # along with Bonsai. If not, see . 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 diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index d96b019df2..4763ef7e8f 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -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 diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 235b1a23d0..297b3eb4df 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -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]) diff --git a/src/bonsai/bonsai/tool/debug.py b/src/bonsai/bonsai/tool/debug.py index b04280e1c8..bda7f3c682 100644 --- a/src/bonsai/bonsai/tool/debug.py +++ b/src/bonsai/bonsai/tool/debug.py @@ -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" diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 840466f62d..81cffcece3 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -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) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index ecccf21fe2..358456b05a 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -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" + ) diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape.py b/src/ifcopenshell-python/ifcopenshell/util/shape.py index c4a3cfe406..0948f9e1f6 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape.py @@ -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.