From 56bfe80b3b30ccc323b9855393e579f1779aa960 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 21 Apr 2025 17:17:09 +1000 Subject: [PATCH] Fix #6501. Fix #6104. Fix #6189. You can now change wall offset convention after drawing walls. People kept on getting confused with the align tool thinking it changed the baseline to reference line offset whereas it actually just aligned the object bodies. This tool now does exactly that. Also start refactoring the "DumbWall" classes into the tools so we can test them properly. --- .../bonsai/bim/module/model/__init__.py | 1 + .../bonsai/bim/module/model/polyline.py | 1 + src/bonsai/bonsai/bim/module/model/profile.py | 3 +- src/bonsai/bonsai/bim/module/model/prop.py | 4 +- src/bonsai/bonsai/bim/module/model/slab.py | 3 +- src/bonsai/bonsai/bim/module/model/wall.py | 110 +++++++----------- .../bonsai/bim/module/model/workspace.py | 11 +- src/bonsai/bonsai/core/model.py | 24 ++-- src/bonsai/bonsai/tool/model.py | 88 ++++++++++++-- src/bonsai/test/bim/feature/model.feature | 89 +++++--------- src/bonsai/test/tool/test_model.py | 41 +++++++ 11 files changed, 218 insertions(+), 157 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 9dbb766399..a8be9df249 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -77,6 +77,7 @@ classes = ( wall.ExtendWallsToWall, wall.FlipWall, wall.MergeWall, + wall.OffsetWalls, wall.RecalculateWall, wall.SplitWall, wall.UnjoinWalls, diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py index 5789f6cb71..505f6d7664 100644 --- a/src/bonsai/bonsai/bim/module/model/polyline.py +++ b/src/bonsai/bonsai/bim/module/model/polyline.py @@ -599,6 +599,7 @@ class PolylineOperator: "Increment Angle": {"icons": True, "keys": ["EVENT_SHIFT", "MOUSE_MMB_SCROLL"]}, "Modify Snap Point": {"icons": True, "keys": ["EVENT_M"]}, "Close Polyline": {"icons": True, "keys": ["EVENT_C"]}, + "Offset": {"icons": True, "keys": ["EVENT_O"]}, "Remove Point": {"icons": True, "keys": ["EVENT_BACKSPACE"]}, } diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index ea3f16ed20..c0fa809515 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -36,7 +36,6 @@ import bonsai.core.root from bonsai.bim.ifc import IfcStore from math import pi, degrees, atan2 from mathutils import Vector, Matrix -from bonsai.bim.module.model.wall import DumbWallRecalculator from bonsai.bim.module.model.decorator import ProfileDecorator, PolylineDecorator, ProductDecorator from bonsai.bim.module.model.polyline import PolylineOperator from typing import Union, Any, Optional @@ -969,7 +968,7 @@ class Rotate90(bpy.types.Operator, tool.Ifc.Operator): obj.matrix_world @= rotate_matrix bpy.context.view_layer.update() DumbProfileRecalculator().recalculate(profile_objs) - DumbWallRecalculator().recalculate(layer2_objs) + tool.Model.recalculate_walls(layer2_objs) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index 66c962b2ae..04f39dd33a 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -221,13 +221,13 @@ class BIMModelProperties(PropertyGroup): items=[("EXTERIOR", "Exterior", ""), ("CENTER", "Center", ""), ("INTERIOR", "Interior", "")], name="Vertical Layer Offset Type", default="EXTERIOR", - description="It's a convention that affects the offset to reference line", + description="Offset convention to reference line", ) offset_type_horizontal: bpy.props.EnumProperty( items=[("TOP", "Top", ""), ("CENTER", "Center", ""), ("BOTTOM", "Bottom", "")], name="Horizontal Layer Offset Type", default="TOP", - description="It's a convention that affects the offset to reference line", + description="Offset convention to reference line", ) offset: bpy.props.FloatProperty(name="Offset", default=0.0, description="Material usage offset from reference line") show_wall_axis: bpy.props.BoolProperty( diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index ab1e8f517e..15a742aa5b 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -36,7 +36,6 @@ from math import cos, pi from mathutils import Vector, Matrix from bonsai.bim.module.model.decorator import ProfileDecorator, PolylineDecorator, ProductDecorator from bonsai.bim.module.model.polyline import PolylineOperator -from bonsai.bim.module.model.wall import DumbWallRecalculator from typing import Optional @@ -1035,5 +1034,5 @@ class RecalculateSlab(bpy.types.Operator, tool.Ifc.Operator): if rel.is_a() == "IfcRelConnectsElements" and rel.RelatedElement.is_a("IfcWall"): walls.append(tool.Ifc.get_object(rel.RelatedElement)) - DumbWallRecalculator().recalculate(walls) + tool.Model.recalculate_walls(walls) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index e7b422c29d..7131307b82 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -117,8 +117,8 @@ class ExtendWallsToWall(bpy.types.Operator, tool.Ifc.Operator): ifcopenshell.api.geometry.connect_wall( tool.Ifc.get(), wall1=element, wall2=target_element, is_atpath=True ) - joiner.recreate_wall(element, obj) - joiner.recreate_wall(target_element, target_obj) + tool.Model.recreate_wall(element, obj) + tool.Model.recreate_wall(target_element, target_obj) else: self.report({"ERROR"}, "Please select at least one LAYER2 element and one active LAYER2 element") @@ -129,10 +129,10 @@ class AlignWall(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} bl_description = """ Align the selected walls to the active wall: 'Ext.': align to the EXTERIOR face - 'C/L': align to wall CENTERLINE + 'C/L': align to wall CENTER 'Int.': align to the INTERIOR face""" - AlignType = Literal["CENTERLINE", "EXTERIOR", "INTERIOR"] + AlignType = Literal["CENTER", "EXTERIOR", "INTERIOR"] align_type: bpy.props.EnumProperty( # type: ignore [reportRedeclaration] items=((i, i, "") for i in get_args(AlignType)) ) @@ -233,7 +233,7 @@ class RecalculateWall(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): objects = tool.Model.get_selected_mesh_ifc_objects() - DumbWallRecalculator().recalculate(objects) + tool.Model.recalculate_walls(objects) return {"FINISHED"} @@ -280,7 +280,7 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): layer2_objs.append(obj) if layer2_objs: - DumbWallRecalculator().recalculate(layer2_objs) + tool.Model.recalculate_walls(layer2_objs) return {"FINISHED"} @@ -393,7 +393,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): obj.rotation_euler.z = current_z_rot if layer2_objs: - DumbWallRecalculator().recalculate(layer2_objs) + tool.Model.recalculate_walls(layer2_objs) return {"FINISHED"} @@ -416,7 +416,24 @@ class ChangeLayerLength(bpy.types.Operator, tool.Ifc.Operator): selected_objs = tool.Model.get_selected_mesh_ifc_objects() for obj in selected_objs: joiner.set_length(obj, self.length) - return {"FINISHED"} + + +class OffsetWalls(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.offset_walls" + bl_label = "Offset Walls" + bl_description = "Offset selected objects from their reference line." + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_mesh_ifc_objects(): + cls.poll_message_set("No mesh IFC objects selected.") + return False + return True + + def _execute(self, context): + props = tool.Model.get_model_props() + core.offset_walls(tool.Ifc, tool.Blender, tool.Model, props.offset_type_vertical) class AddWallsFromSlab(bpy.types.Operator, tool.Ifc.Operator): @@ -487,12 +504,8 @@ class DrawPolylineWall(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): material_set_usage = model.by_id(material.id()) # if material.is_a("IfcMaterialLayerSetUsage"): attributes = {"OffsetFromReferenceLine": offset, "DirectionSense": direction_sense} - ifcopenshell.api.run( - "material.edit_layer_usage", - model, - **{"usage": material_set_usage, "attributes": attributes}, - ) - DumbWallRecalculator().recalculate([wall["obj"]]) + ifcopenshell.api.run("material.edit_layer_usage", model, usage=material_set_usage, attributes=attributes) + tool.Model.recalculate_walls([wall["obj"]]) if walls: if is_polyline_closed: @@ -682,30 +695,6 @@ class DumbWallAligner: return round(degrees(angle) % 360) == 180 -class DumbWallRecalculator: - def recalculate(self, walls: list[bpy.types.Object]) -> None: - queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set() - for wall in walls: - element = tool.Ifc.get_entity(wall) - if tool.Ifc.is_moved(wall): - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall) - queue.add((element, wall)) - for rel in getattr(element, "ConnectedTo", []): - obj = tool.Ifc.get_object(rel.RelatedElement) - if tool.Ifc.is_moved(obj): - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) - queue.add((rel.RelatedElement, obj)) - for rel in getattr(element, "ConnectedFrom", []): - obj = tool.Ifc.get_object(rel.RelatingElement) - if tool.Ifc.is_moved(obj): - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) - queue.add((rel.RelatingElement, obj)) - joiner = DumbWallJoiner() - for element, wall in queue: - if tool.Model.get_usage_type(element) == "LAYER2" and wall: - joiner.recreate_wall(element, wall) - - class DumbWallGenerator: def __init__(self, relating_type): self.relating_type = relating_type @@ -937,7 +926,7 @@ class DumbWallPlaner: else: for rel in inverse.AssociatedTo: walls.extend([tool.Ifc.get_object(e) for e in rel.RelatedObjects]) - DumbWallRecalculator().recalculate([w for w in set(walls) if w]) + tool.Model.recalculate_walls([w for w in set(walls) if w]) def regenerate_from_type(self, usecase_path, ifc_file, settings): relating_type = settings["relating_type"] @@ -968,7 +957,7 @@ class DumbWallPlaner: if layer_set_direction: material.LayerSetDirection = layer_set_direction if material.LayerSetDirection == "AXIS2": - DumbWallRecalculator().recalculate([obj]) + tool.Model.recalculate_walls([obj]) class DumbWallJoiner: @@ -988,7 +977,7 @@ class DumbWallJoiner: axis1 = tool.Model.get_wall_axis(wall1) axis = copy.deepcopy(axis1["reference"]) body = copy.deepcopy(axis1["reference"]) - self.recreate_wall(element1, wall1, axis, body) + tool.Model.recreate_wall(element1, wall1) def split(self, wall1: bpy.types.Object, target: Vector) -> None: unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) @@ -1089,8 +1078,8 @@ class DumbWallJoiner: self.set_axis(element1, p1, p3) self.set_axis(element2, p3, p2) - self.recreate_wall(element1, wall1) - self.recreate_wall(element2, wall2) + tool.Model.recreate_wall(element1, wall1) + tool.Model.recreate_wall(element2, wall2) def flip(self, wall1: bpy.types.Object) -> None: if tool.Ifc.is_moved(wall1): @@ -1117,7 +1106,7 @@ class DumbWallJoiner: ifcopenshell.api.geometry.edit_object_placement( tool.Ifc.get(), product=element1, matrix=matrix, is_si=False, should_transform_children=False ) - self.recreate_wall(element1, wall1) + tool.Model.recreate_wall(element1, wall1) def merge(self, wall1: bpy.types.Object, wall2: bpy.types.Object) -> None: if tool.Ifc.is_moved(wall1): @@ -1170,7 +1159,7 @@ class DumbWallJoiner: related_connection=rel.RelatedConnectionType, ) - self.recreate_wall(element1, wall1) + tool.Model.recreate_wall(element1, wall1) tool.Geometry.delete_ifc_object(wall2) @@ -1203,7 +1192,7 @@ class DumbWallJoiner: description="TOP", ) - self.recreate_wall(element1, wall1) + tool.Model.recreate_wall(element1, wall1) def set_axis(self, wall, p1, p2): axis = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW") @@ -1232,7 +1221,7 @@ class DumbWallJoiner: self.set_axis(element1, p1, intersect) else: self.set_axis(element1, intersect, p2) - self.recreate_wall(element1, wall1) + tool.Model.recreate_wall(element1, wall1) def set_length(self, wall1: bpy.types.Object, si_length: float) -> None: element1 = tool.Ifc.get_entity(wall1) @@ -1246,7 +1235,7 @@ class DumbWallJoiner: p1, p2 = ifcopenshell.util.representation.get_reference_line(element1) p2[0] = p1[0] + si_length / unit_scale self.set_axis(element1, p1, p2) - self.recreate_wall(element1, wall1) + tool.Model.recreate_wall(element1, wall1) def join_T(self, wall1: bpy.types.Object, wall2: bpy.types.Object) -> None: element1 = tool.Ifc.get_entity(wall1) @@ -1269,7 +1258,7 @@ class DumbWallJoiner: description="BUTT", ) - self.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"]) + tool.Model.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"]) def connect(self, obj1: bpy.types.Object, obj2: bpy.types.Object) -> None: wall1 = tool.Ifc.get_entity(obj1) @@ -1279,27 +1268,8 @@ class DumbWallJoiner: if tool.Ifc.is_moved(obj2): bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj2) ifcopenshell.api.geometry.connect_wall(tool.Ifc.get(), wall1=wall1, wall2=wall2) - self.recreate_wall(wall1, obj1) - self.recreate_wall(wall2, obj2) - - def recreate_wall(self, element: ifcopenshell.entity_instance, obj: bpy.types.Object, axis=None, body=None) -> None: - rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element) - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=rep, - should_reload=True, - is_global=True, - should_sync_changes_first=False, - ) - tool.Geometry.record_object_materials(obj) - - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) - matrix[:, 3] *= unit_scale - obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix) - tool.Geometry.record_object_position(obj) + tool.Model.recreate_wall(wall1, obj1) + tool.Model.recreate_wall(wall2, obj2) def create_matrix(self, p, x, y, z): return Matrix([x, y, z, p]).to_4x4().transposed() diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 6d65646599..90a9849e30 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -773,6 +773,12 @@ class EditObjectUI: op = row.operator("bim.change_extrusion_x_angle", icon="FILE_REFRESH", text="") op.x_angle = cls.props.x_angle + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + row.prop( + data=cls.props, property="offset_type_vertical", text="Offset" if ui_context != "TOOL_HEADER" else "" + ) + row.operator("bim.offset_walls", icon="FILE_REFRESH", text="") + elif AuthoringData.data["active_material_usage"] == "LAYER3": row.prop(data=cls.props, property="x_angle", text="Angle" if ui_context != "TOOL_HEADER" else "A") op = row.operator("bim.change_extrusion_x_angle", icon="FILE_REFRESH", text="") @@ -1182,12 +1188,12 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): return if self.active_material_usage == "LAYER2": try: - core.align_walls(tool.Ifc, tool.Blender, tool.Model, DumbWallAligner(), "CENTERLINE") + core.align_walls(tool.Ifc, tool.Blender, tool.Model, DumbWallAligner(), "CENTER") except core.RequireAtLeastTwoLayeredElements as e: self.report({"ERROR"}, str(e)) else: try: - core.align_objects(tool.Blender, tool.Model, "CENTERLINE") + core.align_objects(tool.Blender, tool.Model, "CENTER") except core.RequireAtLeastTwoElements as e: self.report({"ERROR"}, str(e)) @@ -1357,7 +1363,6 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): except core.RequireAtLeastTwoElements as e: self.report({"ERROR"}, str(e)) - def hotkey_S_Y(self): if not bpy.context.selected_objects: return diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index 7e7eac6045..5c4cade5c5 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -27,6 +27,9 @@ if TYPE_CHECKING: from mathutils import Vector from bonsai.bim.module.model.wall import DumbWallJoiner, DumbWallAligner + AlignType = Literal["CENTER", "EXTERIOR", "INTERIOR"] + OffsetType = Literal["CENTER", "EXTERIOR", "INTERIOR"] + def unjoin_walls( ifc: tool.Ifc, blender: tool.Blender, geometry: tool.Geometry, joiner: DumbWallJoiner, model: tool.Model @@ -82,12 +85,19 @@ def join_walls_LV( joiner.connect(another_selected_object, active_obj) +def offset_walls(ifc: tool.Ifc, blender: tool.Blender, model: tool.Model, offset_type: OffsetType): + objs = [ + obj + for obj in blender.get_selected_objects() + if (element := ifc.get_entity(obj)) and model.get_usage_type(element) == "LAYER2" + ] + for obj in objs: + model.offset_wall(obj, offset_type) + model.recalculate_walls(objs) + + def align_walls( - ifc: tool.Ifc, - blender: tool.Blender, - model: tool.Model, - aligner: DumbWallAligner, - align_type: Literal["CENTERLINE", "EXTERIOR", "INTERIOR"], + ifc: tool.Ifc, blender: tool.Blender, model: tool.Model, aligner: DumbWallAligner, align_type: AlignType ): reference_obj = blender.get_active_object(is_selected=True) if not (e := ifc.get_entity(reference_obj) or not model.get_usage_type(e) == "LAYER2"): @@ -103,7 +113,7 @@ def align_walls( ) aligner.set_reference_wall(reference_obj) for obj in objs: - if align_type == "CENTERLINE": + if align_type == "CENTER": aligner.align_centerline(obj) elif align_type == "EXTERIOR": aligner.align_first_layer(obj) @@ -111,7 +121,7 @@ def align_walls( aligner.align_last_layer(obj) -def align_objects(blender: tool.Blender, model: tool.Model, align_type: Literal["CENTERLINE", "POSITIVE", "NEGATIVE"]): +def align_objects(blender: tool.Blender, model: tool.Model, align_type: Literal["CENTER", "POSITIVE", "NEGATIVE"]): reference_obj = blender.get_active_object(is_selected=True) objs = [o for o in blender.get_selected_objects() if o != reference_obj] if not reference_obj or not objs: diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 9dd2222959..e179de44e7 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -2328,11 +2328,14 @@ class Model(bonsai.core.tool.Model): return [s for s in group_node.inputs if s.type != "GEOMETRY"] @classmethod - def align_objects(cls, reference_obj: bpy.types.Object, objs: Iterable[bpy.types.Object], align_type: Literal["CENTERLINE", "POSITIVE", "NEGATIVE"]): - if align_type == "CENTERLINE": - point = reference_obj.matrix_world @ ( - Vector(reference_obj.bound_box[0]) + (reference_obj.dimensions / 2) - ) + def align_objects( + cls, + reference_obj: bpy.types.Object, + objs: Iterable[bpy.types.Object], + align_type: Literal["CENTER", "POSITIVE", "NEGATIVE"], + ): + if align_type == "CENTER": + point = reference_obj.matrix_world @ (Vector(reference_obj.bound_box[0]) + (reference_obj.dimensions / 2)) elif align_type == "POSITIVE": point = reference_obj.matrix_world @ Vector(reference_obj.bound_box[6]) elif align_type == "NEGATIVE": @@ -2351,10 +2354,16 @@ class Model(bonsai.core.tool.Model): obj.matrix_world = Matrix.Translation(reference_y_axis * -y_distances[i]) @ obj.matrix_world @classmethod - def get_axis_distances(cls, point: Vector, axis: Vector, objs: Iterable[bpy.types.Object], align_type: Literal["CENTERLINE", "POSITIVE", "NEGATIVE"]) -> list[float]: + def get_axis_distances( + cls, + point: Vector, + axis: Vector, + objs: Iterable[bpy.types.Object], + align_type: Literal["CENTER", "POSITIVE", "NEGATIVE"], + ) -> list[float]: results = [] for obj in objs: - if align_type == "CENTERLINE": + if align_type == "CENTER": obj_point = obj.matrix_world @ (Vector(obj.bound_box[0]) + (obj.dimensions / 2)) elif align_type == "POSITIVE": obj_point = obj.matrix_world @ Vector(obj.bound_box[6]) @@ -2364,7 +2373,66 @@ class Model(bonsai.core.tool.Model): return results @classmethod - def offset_wall(cls, wall: bpy.types.Object, baseline: Literal["EXTERIOR", "INTERIOR", "CENTERLINE"]) -> None: + def offset_wall(cls, wall: bpy.types.Object, baseline: Literal["EXTERIOR", "INTERIOR", "CENTER"]) -> None: element = tool.Ifc.get_entity(wall) - if baseline == "CENTERLINE": - pass + usage = ifcopenshell.util.element.get_material(element) + if not usage.is_a("IfcMaterialLayerSetUsage"): + return + layer_set = usage.ForLayerSet + if baseline == "CENTER": + if usage.DirectionSense == "POSITIVE": + usage.OffsetFromReferenceLine = -layer_set.TotalThickness / 2 + else: + usage.OffsetFromReferenceLine = layer_set.TotalThickness / 2 + elif baseline == "INTERIOR": + if usage.DirectionSense == "POSITIVE": + usage.OffsetFromReferenceLine = -layer_set.TotalThickness + else: + usage.OffsetFromReferenceLine = 0.0 + elif baseline == "EXTERIOR": + if usage.DirectionSense == "POSITIVE": + usage.OffsetFromReferenceLine = 0.0 + else: + usage.OffsetFromReferenceLine = layer_set.TotalThickness + + @classmethod + def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: + rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=rep, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) + tool.Geometry.record_object_materials(obj) + + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) + matrix[:, 3] *= unit_scale + obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix) + tool.Geometry.record_object_position(obj) + + @classmethod + def recalculate_walls(cls, walls: list[bpy.types.Object]) -> None: + queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set() + for wall in walls: + element = tool.Ifc.get_entity(wall) + if tool.Ifc.is_moved(wall): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall) + queue.add((element, wall)) + for rel in getattr(element, "ConnectedTo", []): + obj = tool.Ifc.get_object(rel.RelatedElement) + if tool.Ifc.is_moved(obj): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + queue.add((rel.RelatedElement, obj)) + for rel in getattr(element, "ConnectedFrom", []): + obj = tool.Ifc.get_object(rel.RelatingElement) + if tool.Ifc.is_moved(obj): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + queue.add((rel.RelatingElement, obj)) + for element, wall in queue: + if tool.Model.get_usage_type(element) == "LAYER2" and wall: + cls.recreate_wall(element, wall) diff --git a/src/bonsai/test/bim/feature/model.feature b/src/bonsai/test/bim/feature/model.feature index cf072bf5a4..bcf57acf73 100644 --- a/src/bonsai/test/bim/feature/model.feature +++ b/src/bonsai/test/bim/feature/model.feature @@ -286,7 +286,28 @@ Scenario: Split a wall which has a flipped door And I press "bim.hotkey(hotkey='S_K')" Then the object "IfcDoor/Door" is at "8.01,0.1,0" -Scenario: Align two walls - centerline +Scenario: Offset walls + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + When the object "IfcWall/Wall" is selected + And I set "scene.BIMModelProperties.offset_type_vertical" to "EXTERIOR" + And I press "bim.offset_walls" + Then the object "IfcWall/Wall" bottom left corner is at "0,0,0" + And the object "IfcWall/Wall" top right corner is at "1,0.1,3" + When I set "scene.BIMModelProperties.offset_type_vertical" to "INTERIOR" + And I press "bim.offset_walls" + Then the object "IfcWall/Wall" bottom left corner is at "0,-0.1,0" + And the object "IfcWall/Wall" top right corner is at "1,0,3" + When I set "scene.BIMModelProperties.offset_type_vertical" to "CENTER" + And I press "bim.offset_walls" + Then the object "IfcWall/Wall" bottom left corner is at "0,-0.05,0" + And the object "IfcWall/Wall" top right corner is at "1,0.05,3" + +Scenario: Align walls Given an empty IFC project And I load the demo construction library And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" @@ -304,46 +325,18 @@ Scenario: Align two walls - centerline And the object "IfcWall/Wall" top right corner is at "1,0.1,3" And the object "IfcWall/Wall.001" bottom left corner is at "10,-0.1,0" And the object "IfcWall/Wall.001" top right corner is at "11,0.2,3" - -Scenario: Align two walls - interior - Given an empty IFC project - And I load the demo construction library - And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" - And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" - And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.add_occurrence" - And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL300'][0].id()" - And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And the cursor is at "10,5,0" - And I press "bim.add_occurrence" - When the object "IfcWall/Wall.001" is selected - And additionally the object "IfcWall/Wall" is selected - And I press "bim.hotkey(hotkey='S_V')" + When I press "bim.hotkey(hotkey='S_V')" Then the object "IfcWall/Wall" bottom left corner is at "0,0,0" And the object "IfcWall/Wall" top right corner is at "1,0.1,3" And the object "IfcWall/Wall.001" bottom left corner is at "10,-0.2,0" And the object "IfcWall/Wall.001" top right corner is at "11,0.1,3" - -Scenario: Align two walls - exterior - Given an empty IFC project - And I load the demo construction library - And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" - And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" - And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.add_occurrence" - And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL300'][0].id()" - And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And the cursor is at "10,5,0" - And I press "bim.add_occurrence" - When the object "IfcWall/Wall.001" is selected - And additionally the object "IfcWall/Wall" is selected - And I press "bim.hotkey(hotkey='S_X')" + When I press "bim.hotkey(hotkey='S_X')" Then the object "IfcWall/Wall" bottom left corner is at "0,0,0" And the object "IfcWall/Wall" top right corner is at "1,0.1,3" And the object "IfcWall/Wall.001" bottom left corner is at "10,0,0" And the object "IfcWall/Wall.001" top right corner is at "11,0.3,3" -Scenario: Align two walls - centerline fail due to selection criteria +Scenario: Align walls - centerline fail due to selection criteria Given an empty IFC project And I load the demo construction library And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" @@ -353,7 +346,7 @@ Scenario: Align two walls - centerline fail due to selection criteria When the object "IfcWall/Wall" is selected Then I press "bim.hotkey(hotkey='S_C')" and expect error "Error: At least two vertically layered elements must be selected to match alignments." -Scenario: Align two elements - centerline +Scenario: Align elements Given an empty IFC project And I load the demo construction library And I set "scene.BIMModelProperties.ifc_class" to "IfcDoorType" @@ -370,38 +363,12 @@ Scenario: Align two elements - centerline And the object "IfcDoor/Door" top right corner is at "1.01,0.1,2.145" And the object "IfcDoor/Door.001" bottom left corner is at "10,-0.455,0" And the object "IfcDoor/Door.001" top right corner is at "9.9,0.555,2.145" - -Scenario: Align two elements - positive - Given an empty IFC project - And I load the demo construction library - And I set "scene.BIMModelProperties.ifc_class" to "IfcDoorType" - And the variable "element_type" is "[e for e in {ifc}.by_type('IfcDoorType') if e.Name == 'DT01'][0].id()" - And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.add_occurrence" - And the cursor is at "10,5,0" - And I press "bim.add_occurrence" - And the object "IfcDoor/Door.001" is rotated by "0,0,90" deg - When the object "IfcDoor/Door.001" is selected - And additionally the object "IfcDoor/Door" is selected - And I press "bim.hotkey(hotkey='S_V')" + When I press "bim.hotkey(hotkey='S_V')" Then the object "IfcDoor/Door" bottom left corner is at "0,0,0" And the object "IfcDoor/Door" top right corner is at "1.01,0.1,2.145" And the object "IfcDoor/Door.001" bottom left corner is at "10,-0.910,0" And the object "IfcDoor/Door.001" top right corner is at "9.9,0.1,2.145" - -Scenario: Align two elements - negative - Given an empty IFC project - And I load the demo construction library - And I set "scene.BIMModelProperties.ifc_class" to "IfcDoorType" - And the variable "element_type" is "[e for e in {ifc}.by_type('IfcDoorType') if e.Name == 'DT01'][0].id()" - And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.add_occurrence" - And the cursor is at "10,5,0" - And I press "bim.add_occurrence" - And the object "IfcDoor/Door.001" is rotated by "0,0,90" deg - When the object "IfcDoor/Door.001" is selected - And additionally the object "IfcDoor/Door" is selected - And I press "bim.hotkey(hotkey='S_X')" + When I press "bim.hotkey(hotkey='S_X')" Then the object "IfcDoor/Door" bottom left corner is at "0,0,0" And the object "IfcDoor/Door" top right corner is at "1.01,0.1,2.145" And the object "IfcDoor/Door.001" bottom left corner is at "10,0,0" diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py index f41a4d5988..b0b7411856 100644 --- a/src/bonsai/test/tool/test_model.py +++ b/src/bonsai/test/tool/test_model.py @@ -678,3 +678,44 @@ class TestApplyIfcMaterialChanges(NewFile): ifcopenshell.api.material.unassign_material(ifc_file, products=[element]) tool.Material.ensure_material_unassigned([element]) assert self.get_mesh(obj).materials[:] == [bpy.data.materials["Red"]] + + +class TestOffsetWall(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + + wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType", name="WAL01") + material_set = ifcopenshell.api.material.add_material_set(ifc, set_type="IfcMaterialLayerSet") + material = ifcopenshell.api.material.add_material(ifc, name="PB01", category="gypsum") + layer = ifcopenshell.api.material.add_layer(ifc, layer_set=material_set, material=material) + ifcopenshell.api.material.edit_layer(ifc, layer=layer, attributes={"LayerThickness": 100}) + ifcopenshell.api.material.assign_material(ifc, products=[wall_type], material=material_set) + + wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall") + ifcopenshell.api.type.assign_type(ifc, related_objects=[wall], relating_type=wall_type) + rel = ifcopenshell.api.material.assign_material(ifc, products=[wall], type="IfcMaterialLayerSetUsage") + usage = rel.RelatingMaterial + obj = bpy.data.objects.new("Wall", None) + tool.Ifc.link(wall, obj) + + usage.DirectionSense = "POSITIVE" + subject.offset_wall(obj, "CENTER") + assert usage.OffsetFromReferenceLine == -50 + usage.DirectionSense = "NEGATIVE" + subject.offset_wall(obj, "CENTER") + assert usage.OffsetFromReferenceLine == 50 + + usage.DirectionSense = "POSITIVE" + subject.offset_wall(obj, "INTERIOR") + assert usage.OffsetFromReferenceLine == -100 + usage.DirectionSense = "NEGATIVE" + subject.offset_wall(obj, "INTERIOR") + assert usage.OffsetFromReferenceLine == 0 + + usage.DirectionSense = "POSITIVE" + subject.offset_wall(obj, "EXTERIOR") + assert usage.OffsetFromReferenceLine == 0 + usage.DirectionSense = "NEGATIVE" + subject.offset_wall(obj, "EXTERIOR") + assert usage.OffsetFromReferenceLine == 100