From f5a1c4aae315406e5511fab3ace0125c554cbd8d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 12 Mar 2025 18:05:51 +1100 Subject: [PATCH] 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. --- .../bonsai/bim/module/model/workspace.py | 15 +++- src/bonsai/bonsai/core/model.py | 17 +++- src/bonsai/bonsai/core/tool.py | 15 ++-- src/bonsai/bonsai/tool/blender.py | 19 +++-- src/bonsai/bonsai/tool/debug.py | 10 +++ src/bonsai/bonsai/tool/geometry.py | 4 + src/bonsai/bonsai/tool/model.py | 84 ++++++++++++++++++- .../ifcopenshell/util/shape.py | 20 +++++ 8 files changed, 166 insertions(+), 18 deletions(-) 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.