From 2db6b860b2a8d5055c515083928cb859cb847d6a Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 21 Apr 2025 15:38:26 +1000 Subject: [PATCH] See #6501. Fix bug where wall alignment didn't strictly check for poll requirements prior to executing operator. --- src/bonsai/bonsai/bim/module/model/product.py | 45 +----- src/bonsai/bonsai/bim/module/model/wall.py | 40 ++---- .../bonsai/bim/module/model/workspace.py | 35 +++-- src/bonsai/bonsai/core/model.py | 47 ++++++- src/bonsai/bonsai/tool/model.py | 43 ++++++ src/bonsai/test/bim/feature/model.feature | 131 ++++++++++++++++++ src/bonsai/test/bim/test_feature.py | 6 +- 7 files changed, 264 insertions(+), 83 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index c53af0665b..fb329ab452 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -577,49 +577,12 @@ class AlignProduct(bpy.types.Operator): align_type: AlignType def execute(self, context): - selected_objs = context.selected_objects - if len(selected_objs) < 2 or not context.active_object: - self.report({"ERROR"}, "Please select atleast 2 objects.") - return {"FINISHED"} - if self.align_type == "CENTERLINE": - point = context.active_object.matrix_world @ ( - Vector(context.active_object.bound_box[0]) + (context.active_object.dimensions / 2) - ) - elif self.align_type == "POSITIVE": - point = context.active_object.matrix_world @ Vector(context.active_object.bound_box[6]) - elif self.align_type == "NEGATIVE": - point = context.active_object.matrix_world @ Vector(context.active_object.bound_box[0]) - else: - assert_never(self.align_type) - - active_x_axis = context.active_object.matrix_world.to_quaternion() @ Vector((1, 0, 0)) - active_y_axis = context.active_object.matrix_world.to_quaternion() @ Vector((0, 1, 0)) - active_z_axis = context.active_object.matrix_world.to_quaternion() @ Vector((0, 0, 1)) - - x_distances = self.get_axis_distances(point, active_x_axis, context) - y_distances = self.get_axis_distances(point, active_y_axis, context) - if abs(sum(x_distances)) < abs(sum(y_distances)): - for i, obj in enumerate(selected_objs): - obj.matrix_world = Matrix.Translation(active_x_axis * -x_distances[i]) @ obj.matrix_world - else: - for i, obj in enumerate(selected_objs): - obj.matrix_world = Matrix.Translation(active_y_axis * -y_distances[i]) @ obj.matrix_world + try: + core.align_objects(tool.Blender, tool.Model, self.align_type) + except core.RequireAtLeastTwoElements as e: + self.report({"ERROR"}, str(e)) return {"FINISHED"} - def get_axis_distances(self, point: Vector, axis: Vector, context: bpy.types.Context) -> list[float]: - results = [] - for obj in context.selected_objects: - if self.align_type == "CENTERLINE": - obj_point = obj.matrix_world @ (Vector(obj.bound_box[0]) + (obj.dimensions / 2)) - elif self.align_type == "POSITIVE": - obj_point = obj.matrix_world @ Vector(obj.bound_box[6]) - elif self.align_type == "NEGATIVE": - obj_point = obj.matrix_world @ Vector(obj.bound_box[0]) - else: - assert_never(self.align_type) - results.append(mathutils.geometry.distance_point_to_plane(obj_point, point, axis)) - return results - class LoadTypeThumbnails(bpy.types.Operator): bl_idname = "bim.load_type_thumbnails" diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index aac50f53de..e7b422c29d 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -140,31 +140,11 @@ class AlignWall(bpy.types.Operator): if TYPE_CHECKING: align_type: AlignType - @classmethod - def poll(cls, context): - if not context.active_object: - cls.poll_message_set("No active object selected.") - return False - selected_valid_objects = tool.Model.get_selected_mesh_objects() - if len(selected_valid_objects) < 2: - cls.poll_message_set("Please select at least two mesh objects.") - return False - return True - def execute(self, context): - selected_objects = tool.Model.get_selected_mesh_objects() - for obj in selected_objects: - if obj == context.active_object: - continue - aligner = DumbWallAligner(obj, context.active_object) - if self.align_type == "CENTERLINE": - aligner.align_centerline() - elif self.align_type == "EXTERIOR": - aligner.align_first_layer() - elif self.align_type == "INTERIOR": - aligner.align_last_layer() - else: - assert_never(self.align_type) + try: + core.align_walls(tool.Ifc, tool.Blender, tool.Model, DumbWallAligner(), self.align_type) + except core.RequireAtLeastTwoLayeredElements as e: + self.report({"ERROR"}, str(e)) return {"FINISHED"} @@ -613,11 +593,11 @@ class DumbWallAligner: # An alignment shifts the origin of all walls to the closest point on the # local X axis of the reference wall. In addition, the Z rotation is copied. # Z translations are ignored for alignment. - def __init__(self, wall: bpy.types.Object, reference_wall: bpy.types.Object): - self.wall = wall + def set_reference_wall(self, reference_wall: bpy.types.Object): self.reference_wall = reference_wall - def align_centerline(self) -> None: + def align_centerline(self, wall: bpy.types.Object) -> None: + self.wall = wall self.align_rotation() l_start = Vector(self.reference_wall.bound_box[0]).lerp(Vector(self.reference_wall.bound_box[3]), 0.5) @@ -635,7 +615,8 @@ class DumbWallAligner: new_origin = point - offset self.wall.matrix_world.translation[0], self.wall.matrix_world.translation[1] = new_origin.xy - def align_last_layer(self) -> None: + def align_last_layer(self, wall: bpy.types.Object) -> None: + self.wall = wall self.align_rotation() if self.is_rotation_flipped(): @@ -658,7 +639,8 @@ class DumbWallAligner: new_origin = point - offset self.wall.matrix_world.translation[0], self.wall.matrix_world.translation[1] = new_origin.xy - def align_first_layer(self) -> None: + def align_first_layer(self, wall: bpy.types.Object) -> None: + self.wall = wall self.align_rotation() if self.is_rotation_flipped(): diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 42a767e726..6d65646599 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -23,7 +23,7 @@ import bpy.utils.previews import bonsai.bim import bonsai.tool as tool import bonsai.core.model as core -from bonsai.bim.module.model.wall import DumbWallJoiner +from bonsai.bim.module.model.wall import DumbWallJoiner, DumbWallAligner from bonsai.bim.helper import prop_with_search, draw_attribute from bpy.types import WorkSpaceTool, Menu from bonsai.bim.module.model.data import AuthoringData, ItemData @@ -1181,10 +1181,15 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): if not bpy.context.selected_objects: return if self.active_material_usage == "LAYER2": - if bpy.ops.bim.align_wall.poll(): - bpy.ops.bim.align_wall(align_type="CENTERLINE") + try: + core.align_walls(tool.Ifc, tool.Blender, tool.Model, DumbWallAligner(), "CENTERLINE") + except core.RequireAtLeastTwoLayeredElements as e: + self.report({"ERROR"}, str(e)) else: - bpy.ops.bim.align_product(align_type="CENTERLINE") + try: + core.align_objects(tool.Blender, tool.Model, "CENTERLINE") + except core.RequireAtLeastTwoElements as e: + self.report({"ERROR"}, str(e)) def hotkey_S_E(self): if not bpy.context.selected_objects or not (active_object := bpy.context.active_object): @@ -1328,18 +1333,30 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): if not bpy.context.selected_objects: return elif self.active_material_usage == "LAYER2": - bpy.ops.bim.align_wall(align_type="INTERIOR") + try: + core.align_walls(tool.Ifc, tool.Blender, tool.Model, DumbWallAligner(), "INTERIOR") + except core.RequireAtLeastTwoLayeredElements as e: + self.report({"ERROR"}, str(e)) else: - bpy.ops.bim.align_product(align_type="POSITIVE") + try: + core.align_objects(tool.Blender, tool.Model, "POSITIVE") + except core.RequireAtLeastTwoElements as e: + self.report({"ERROR"}, str(e)) def hotkey_S_X(self): if not bpy.context.selected_objects: return if self.active_material_usage == "LAYER2": - if bpy.ops.bim.align_wall.poll(): - bpy.ops.bim.align_wall(align_type="EXTERIOR") + try: + core.align_walls(tool.Ifc, tool.Blender, tool.Model, DumbWallAligner(), "EXTERIOR") + except core.RequireAtLeastTwoLayeredElements as e: + self.report({"ERROR"}, str(e)) else: - bpy.ops.bim.align_product(align_type="NEGATIVE") + try: + core.align_objects(tool.Blender, tool.Model, "NEGATIVE") + except core.RequireAtLeastTwoElements as e: + self.report({"ERROR"}, str(e)) + def hotkey_S_Y(self): if not bpy.context.selected_objects: diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index 77738abb2f..7e7eac6045 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: import bonsai.tool as tool from mathutils import Vector - from bonsai.bim.module.model.wall import DumbWallJoiner + from bonsai.bim.module.model.wall import DumbWallJoiner, DumbWallAligner def unjoin_walls( @@ -82,6 +82,43 @@ def join_walls_LV( joiner.connect(another_selected_object, active_obj) +def align_walls( + ifc: tool.Ifc, + blender: tool.Blender, + model: tool.Model, + aligner: DumbWallAligner, + align_type: Literal["CENTERLINE", "EXTERIOR", "INTERIOR"], +): + 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"): + reference_obj = None + objs = [ + o + for o in blender.get_selected_objects() + if o != reference_obj and (e := ifc.get_entity(o)) and model.get_usage_type(e) == "LAYER2" + ] + if not reference_obj or not objs: + raise RequireAtLeastTwoLayeredElements( + "At least two vertically layered elements must be selected to match alignments." + ) + aligner.set_reference_wall(reference_obj) + for obj in objs: + if align_type == "CENTERLINE": + aligner.align_centerline(obj) + elif align_type == "EXTERIOR": + aligner.align_first_layer(obj) + elif align_type == "INTERIOR": + aligner.align_last_layer(obj) + + +def align_objects(blender: tool.Blender, model: tool.Model, align_type: Literal["CENTERLINE", "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: + raise RequireAtLeastTwoElements("At least two objects must be selected to match alignments.") + model.align_objects(reference_obj, objs, align_type) + + def extend_wall_to_slab( ifc: tool.Ifc, geometry: tool.Geometry, @@ -145,3 +182,11 @@ class RequireTwoWallsError(Exception): class RequireAtLeastTwoLayeredElements(Exception): pass + + +class RequireAtLeastTwoElements(Exception): + pass + + +class RequireLayeredElement(Exception): + pass diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index d5a8ec75f9..9dd2222959 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -39,6 +39,7 @@ import ifcopenshell.util.unit import bonsai.core.geometry import bonsai.core.tool import bonsai.tool as tool +import mathutils from math import atan, cos, degrees, pi, radians from mathutils import Matrix, Vector from copy import deepcopy @@ -2325,3 +2326,45 @@ class Model(bonsai.core.tool.Model): assert isinstance(group_node, bpy.types.GeometryNodeGroup) 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) + ) + elif align_type == "POSITIVE": + point = reference_obj.matrix_world @ Vector(reference_obj.bound_box[6]) + elif align_type == "NEGATIVE": + point = reference_obj.matrix_world @ Vector(reference_obj.bound_box[0]) + + reference_x_axis = reference_obj.matrix_world.col[0].to_3d() + reference_y_axis = reference_obj.matrix_world.col[1].to_3d() + + x_distances = cls.get_axis_distances(point, reference_x_axis, objs, align_type) + y_distances = cls.get_axis_distances(point, reference_y_axis, objs, align_type) + if abs(sum(x_distances)) < abs(sum(y_distances)): + for i, obj in enumerate(objs): + obj.matrix_world = Matrix.Translation(reference_x_axis * -x_distances[i]) @ obj.matrix_world + else: + for i, obj in enumerate(objs): + 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]: + results = [] + for obj in objs: + if align_type == "CENTERLINE": + 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]) + elif align_type == "NEGATIVE": + obj_point = obj.matrix_world @ Vector(obj.bound_box[0]) + results.append(mathutils.geometry.distance_point_to_plane(obj_point, point, axis)) + return results + + @classmethod + def offset_wall(cls, wall: bpy.types.Object, baseline: Literal["EXTERIOR", "INTERIOR", "CENTERLINE"]) -> None: + element = tool.Ifc.get_entity(wall) + if baseline == "CENTERLINE": + pass diff --git a/src/bonsai/test/bim/feature/model.feature b/src/bonsai/test/bim/feature/model.feature index 4154a3a535..cf072bf5a4 100644 --- a/src/bonsai/test/bim/feature/model.feature +++ b/src/bonsai/test/bim/feature/model.feature @@ -286,6 +286,137 @@ 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 + 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_C')" + 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.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')" + 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')" + 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 + 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 + 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 + 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_C')" + 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.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')" + 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')" + 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" + And the object "IfcDoor/Door.001" top right corner is at "9.9,1.01,2.145" + +Scenario: Align elements - 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 "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" + When the object "IfcDoor/Door" is selected + Then I press "bim.hotkey(hotkey='S_C')" and expect error "Error: At least two objects must be selected to match alignments." + Scenario: Add a slab Given an empty IFC project And I load the demo construction library diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index 3bf44b935f..6409d33acf 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -1264,7 +1264,7 @@ def the_object_name_is_at_location(name, location): obj_location = the_object_name_exists(name).location assert ( obj_location - Vector([float(co) for co in location.split(",")]) - ).length < 0.1, f"Object is at {obj_location} instead of {location}" + ).length < 0.05, f"Object is at {obj_location} instead of {location}" @then(parsers.parse('the object "{name}" has a vertex at "{location}"')) @@ -1305,7 +1305,7 @@ def the_object_name_top_right_corner_is_at_location(name, location): obj_corner = obj.matrix_world @ Vector(obj.bound_box[6]) assert ( obj_corner - Vector([float(co) for co in location.split(",")]) - ).length < 0.1, f"Object has top right corner {obj_corner} instead of {location}" + ).length < 0.05, f"Object has top right corner {obj_corner} instead of {location}" @then(parsers.parse('the object "{name}" bottom left corner is at "{location}"')) @@ -1314,7 +1314,7 @@ def the_object_name_bottom_left_corner_is_at_location(name, location): obj_corner = obj.matrix_world @ Vector(obj.bound_box[0]) assert ( obj_corner - Vector([float(co) for co in location.split(",")]) - ).length < 0.1, f"Object has bottom left corner {obj_corner} instead of {location}" + ).length < 0.05, f"Object has bottom left corner {obj_corner} instead of {location}" @then(parsers.parse('the object "{name}" is contained in "{container_name}"'))