See #6501. Fix bug where wall alignment didn't strictly check for poll requirements prior to executing operator.

This commit is contained in:
Dion Moult
2025-04-21 15:38:26 +10:00
parent 5af2c3914a
commit 2db6b860b2
7 changed files with 264 additions and 83 deletions
+4 -41
View File
@@ -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"
+11 -29
View File
@@ -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():
@@ -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:
+46 -1
View File
@@ -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
+43
View File
@@ -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
+131
View File
@@ -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
+3 -3
View File
@@ -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}"'))