Files
IfcOpenShell/src/blenderbim/blenderbim/bim/module/model/wall.py
T
Dion Moult ffe9ac6614 Run black
2022-08-01 19:45:36 +10:00

1042 lines
44 KiB
Python

# BlenderBIM Add-on - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of BlenderBIM Add-on.
#
# BlenderBIM Add-on is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BlenderBIM Add-on is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with BlenderBIM Add-on. If not, see <http://www.gnu.org/licenses/>.
import bpy
import math
import bmesh
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.util.unit
import ifcopenshell.util.element
import ifcopenshell.util.representation
import mathutils.geometry
import blenderbim.bim.handler
import blenderbim.core.type
import blenderbim.core.root
import blenderbim.core.geometry
import blenderbim.tool as tool
from blenderbim.bim.ifc import IfcStore
from ifcopenshell.api.pset.data import Data as PsetData
from ifcopenshell.api.material.data import Data as MaterialData
from math import pi, degrees
from mathutils import Vector, Matrix
def element_listener(element, obj):
blenderbim.bim.handler.subscribe_to(obj, "mode", mode_callback)
def mode_callback(obj, data):
for obj in set(bpy.context.selected_objects + [bpy.context.active_object]):
if (
not obj.data
or not isinstance(obj.data, (bpy.types.Mesh, bpy.types.Curve, bpy.types.TextCurve))
or not obj.BIMObjectProperties.ifc_definition_id
or not bpy.context.scene.BIMProjectProperties.is_authoring
):
return
product = IfcStore.get_file().by_id(obj.BIMObjectProperties.ifc_definition_id)
parametric = ifcopenshell.util.element.get_psets(product).get("EPset_Parametric")
if not parametric or parametric["Engine"] != "BlenderBIM.DumbLayer2":
return
if obj.mode == "EDIT":
bpy.ops.bim.dynamically_void_product(obj=obj.name)
IfcStore.edited_objs.add(obj)
bm = bmesh.from_edit_mesh(obj.data)
bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 1, verts=bm.verts, edges=bm.edges)
bmesh.update_edit_mesh(obj.data)
bm.free()
else:
new_origin = obj.matrix_world @ Vector(obj.bound_box[0])
obj.data.transform(
Matrix.Translation(
(obj.matrix_world.inverted().to_quaternion() @ (obj.matrix_world.translation - new_origin))
)
)
obj.matrix_world.translation = new_origin
class JoinWall(bpy.types.Operator):
bl_idname = "bim.join_wall"
bl_label = "Join Wall"
bl_options = {"REGISTER", "UNDO"}
bl_description = """ Trim/Extend the selected walls to the last selected wall:
'T' mode: Trim/Extend to the virtual projection
'L' mode: Chamfer the walls
'V' mode: Chamfer the walls keeping the angle"""
join_type: bpy.props.StringProperty()
@classmethod
def poll(cls, context):
return context.selected_objects
def execute(self, context):
selected_objs = [o for o in context.selected_objects if o.BIMObjectProperties.ifc_definition_id]
for obj in selected_objs:
bpy.ops.bim.dynamically_void_product(obj=obj.name)
if not self.join_type:
for obj in selected_objs:
DumbWallJoiner(obj, obj).unjoin()
return {"FINISHED"}
if not context.active_object:
return {"FINISHED"}
if len(selected_objs) == 1:
DumbWallJoiner(context.active_object, target_coordinate=context.scene.cursor.location).extend()
IfcStore.edited_objs.add(context.active_object)
return {"FINISHED"}
if len(selected_objs) < 2:
return {"FINISHED"}
for obj in selected_objs:
if obj == context.active_object:
continue
joiner = DumbWallJoiner(obj, context.active_object)
if self.join_type == "T":
joiner.join_T()
elif self.join_type == "L":
joiner.join_L()
elif self.join_type == "V":
joiner.join_V()
IfcStore.edited_objs.add(obj)
if self.join_type != "T":
IfcStore.edited_objs.add(context.active_object)
return {"FINISHED"}
class AlignWall(bpy.types.Operator):
bl_idname = "bim.align_wall"
bl_label = "Align Wall"
bl_options = {"REGISTER", "UNDO"}
bl_description = """ Align the selected walls to the last selected wall:
'Ext.': align to the EXTERIOR face
'C/L': align to wall CENTERLINE
'Int.': align to the INTERIOR face"""
align_type: bpy.props.StringProperty()
@classmethod
def poll(cls, context):
selected_valid_objects = [o for o in context.selected_objects if o.data and hasattr(o.data, "transform")]
return context.active_object and len(selected_valid_objects) > 1
def execute(self, context):
selected_objects = [o for o in context.selected_objects if o.data and hasattr(o.data, "transform")]
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()
IfcStore.edited_objs.add(obj)
return {"FINISHED"}
class FlipWall(bpy.types.Operator):
bl_idname = "bim.flip_wall"
bl_label = "Flip Wall"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Switch the origin from the min XY corner to the max XY corner, and rotates the origin by 180"
@classmethod
def poll(cls, context):
return context.selected_objects
def execute(self, context):
selected_objs = [o for o in context.selected_objects if o.data and hasattr(o.data, "transform")]
for obj in selected_objs:
DumbWallFlipper(obj).flip()
IfcStore.edited_objs.add(obj)
return {"FINISHED"}
class SplitWall(bpy.types.Operator):
bl_idname = "bim.split_wall"
bl_label = "Split Wall"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Split selected wall into two walls in correspondence of Blender cursor. The cursor must be in the wall volume"
)
@classmethod
def poll(cls, context):
return context.selected_objects
def execute(self, context):
selected_objs = [o for o in context.selected_objects if o.data and hasattr(o.data, "transform")]
for obj in selected_objs:
DumbWallSplitter(obj, context.scene.cursor.location).split()
IfcStore.edited_objs.add(obj)
return {"FINISHED"}
def recalculate_dumb_wall_origin(wall, new_origin=None):
if new_origin is None:
new_origin = wall.matrix_world @ Vector(wall.bound_box[0])
if (wall.matrix_world.translation - new_origin).length < 0.001:
return
wall.data.transform(
Matrix.Translation(
(wall.matrix_world.inverted().to_quaternion() @ (wall.matrix_world.translation - new_origin))
)
)
wall.matrix_world.translation = new_origin
for child in wall.children:
child.matrix_parent_inverse = wall.matrix_world.inverted()
class DumbWallSplitter:
def __init__(self, wall, point):
self.wall = wall
self.point = point
def split(self):
recalculate_dumb_wall_origin(self.wall)
self.point = self.determine_split_point()
if not self.point:
return
new_wall = self.duplicate_wall()
self.snap_end_face_to_point(self.wall, "max")
self.snap_end_face_to_point(new_wall, "min")
def determine_split_point(self):
start = self.wall.matrix_world @ Vector(self.wall.bound_box[0])
end = self.wall.matrix_world @ Vector(self.wall.bound_box[4])
point, distance = mathutils.geometry.intersect_point_line(self.point, start, end)
if round(distance, 2) <= 0 or round(distance, 2) >= 1:
return # The split point is not on the wall
return point
def duplicate_wall(self):
new = self.wall.copy()
self.wall.users_collection[0].objects.link(new)
blenderbim.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new)
return new
def snap_end_face_to_point(self, wall, which_end):
bm = bmesh.new()
bm.from_mesh(wall.data)
bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 1, verts=bm.verts, edges=bm.edges)
min_face, max_face = self.get_wall_end_faces(wall, bm)
face = min_face if which_end == "min" else max_face
local_point = wall.matrix_world.inverted() @ self.point
for vert in face.verts:
vert.co.x = local_point.x
bm.to_mesh(wall.data)
wall.data.update()
bm.free()
IfcStore.edited_objs.add(wall)
# An end face is a quad that is on one end of the wall or the other. It must
# have at least one vertex on either extreme X-axis, and a non-insignificant
# X component of its face normal
def get_wall_end_faces(self, wall, bm):
min_face = None
max_face = None
min_x = min([v[0] for v in wall.bound_box])
max_x = max([v[0] for v in wall.bound_box])
bm.faces.ensure_lookup_table()
for f in bm.faces:
for v in f.verts:
if v.co.x == min_x and abs(f.normal.x) > 0.1:
min_face = f
elif v.co.x == max_x and abs(f.normal.x) > 0.1:
max_face = f
if min_face and max_face:
break
return min_face, max_face
class DumbWallFlipper:
# A flip switches the origin from the min XY corner to the max XY corner, and rotates the origin by 180.
def __init__(self, wall):
self.wall = wall
def flip(self):
if (
self.wall.matrix_world.translation - self.wall.matrix_world @ Vector(self.wall.bound_box[0])
).length < 0.001:
recalculate_dumb_wall_origin(self.wall, self.wall.matrix_world @ Vector(self.wall.bound_box[7]))
self.rotate_wall_180()
bpy.context.view_layer.update()
for child in self.wall.children:
child.matrix_parent_inverse = self.wall.matrix_world.inverted()
else:
recalculate_dumb_wall_origin(self.wall)
def rotate_wall_180(self):
flip_matrix = Matrix.Rotation(pi, 4, "Z")
self.wall.data.transform(flip_matrix)
self.wall.rotation_euler.rotate(flip_matrix)
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, reference_wall):
self.wall = wall
self.reference_wall = reference_wall
def align_centerline(self):
recalculate_dumb_wall_origin(self.wall)
recalculate_dumb_wall_origin(self.reference_wall)
self.align_rotation()
width = (Vector(self.wall.bound_box[3]) - Vector(self.wall.bound_box[0])).y
reference_width = (Vector(self.reference_wall.bound_box[3]) - Vector(self.reference_wall.bound_box[0])).y
if self.is_rotation_flipped():
offset = self.wall.matrix_world.to_quaternion() @ Vector((0, -(reference_width / 2) - (width / 2), 0))
else:
offset = self.wall.matrix_world.to_quaternion() @ Vector((0, (reference_width / 2) - (width / 2), 0))
self.align(
self.reference_wall.matrix_world @ Vector(self.reference_wall.bound_box[0]),
self.reference_wall.matrix_world @ Vector(self.reference_wall.bound_box[4]),
offset,
)
def align_last_layer(self):
recalculate_dumb_wall_origin(self.wall)
recalculate_dumb_wall_origin(self.reference_wall)
self.align_rotation()
if self.is_rotation_flipped():
DumbWallFlipper(self.wall).flip()
bpy.context.view_layer.update()
start = self.reference_wall.matrix_world @ Vector(self.reference_wall.bound_box[3])
end = self.reference_wall.matrix_world @ Vector(self.reference_wall.bound_box[7])
wall_width = (Vector(self.wall.bound_box[3]) - Vector(self.wall.bound_box[0])).y
offset = self.wall.matrix_world.to_quaternion() @ Vector((0, -wall_width, 0))
self.align(start, end, offset)
def align_first_layer(self):
recalculate_dumb_wall_origin(self.wall)
recalculate_dumb_wall_origin(self.reference_wall)
self.align_rotation()
if self.is_rotation_flipped():
DumbWallFlipper(self.wall).flip()
bpy.context.view_layer.update()
start = self.reference_wall.matrix_world @ Vector(self.reference_wall.bound_box[0])
end = self.reference_wall.matrix_world @ Vector(self.reference_wall.bound_box[4])
self.align(start, end)
def align(self, start, end, offset=None):
if offset is None:
offset = Vector((0, 0, 0))
point, distance = mathutils.geometry.intersect_point_line(self.wall.matrix_world.translation, start, end)
new_origin = point + offset
self.wall.matrix_world.translation[0] = new_origin[0]
self.wall.matrix_world.translation[1] = new_origin[1]
def align_rotation(self):
reference = (self.reference_wall.matrix_world.to_quaternion() @ Vector((1, 0, 0))).to_2d()
wall = (self.wall.matrix_world.to_quaternion() @ Vector((1, 0, 0))).to_2d()
angle = reference.angle_signed(wall)
if round(degrees(angle) % 360) in (0, 180):
return
elif angle > (pi / 2):
self.wall.rotation_euler[2] -= pi - angle
else:
self.wall.rotation_euler[2] += angle
bpy.context.view_layer.update()
def is_rotation_flipped(self):
reference = (self.reference_wall.matrix_world.to_quaternion() @ Vector((1, 0, 0))).to_2d()
wall = (self.wall.matrix_world.to_quaternion() @ Vector((1, 0, 0))).to_2d()
angle = reference.angle_signed(wall)
return round(degrees(angle) % 360) == 180
class DumbWallJoiner:
# A dumb wall is a prismatic wall along its local X axis.
# Given two dumb walls, there are three types of wall joints.
# 1. T-junction joints
# 2. L-junction "butt" joints
# 3. V-junction "mitre" joints
# The algorithms that handle all joints rely on three fundamental functions.
# 1. Identify faces at either end of the wall, called "end faces".
# 2. Given an "end face", identify a side "target face" of the other wall
# to project towards.
# 3. Project the vertices of an "end face" to the "target face".
# Alternatively, a target coordinate may be provided as an imaginary point for the wall to join to
def __init__(self, wall1, wall2=None, target_coordinate=None):
self.wall1 = wall1
self.wall2 = wall2
self.target_coordinate = target_coordinate
self.should_project_to_frontface = True
self.should_attempt_v_junction_projection = False
self.initialise_convenience_variables()
def initialise_convenience_variables(self):
self.wall1_matrix = self.wall1.matrix_world
if self.wall2:
self.wall2_matrix = self.wall2.matrix_world
self.pos_x = self.wall1_matrix.to_quaternion() @ Vector((1, 0, 0))
self.neg_x = self.wall1_matrix.to_quaternion() @ Vector((-1, 0, 0))
# Unjoining a wall geometrically means to flatten the ends of the wall to
# remove any mitred angle from it.
def unjoin(self):
wall1_min_faces, wall1_max_faces = self.get_wall_end_faces(self.wall1)
min_x = min([v[0] for v in self.wall1.bound_box])
max_x = max([v[0] for v in self.wall1.bound_box])
for face in wall1_min_faces:
for v in face.vertices:
self.wall1.data.vertices[v].co[0] = min_x
for face in wall1_max_faces:
for v in face.vertices:
self.wall1.data.vertices[v].co[0] = max_x
self.recalculate_origins()
# An extension is where a single end of wall1 is projected to an imaginary
# plane denoted by the target coordinate.
def extend(self):
wall1_min_faces, wall1_max_faces = self.get_wall_end_faces(self.wall1)
ef1_distance = abs(
mathutils.geometry.distance_point_to_plane(
self.wall1_matrix @ self.wall1.data.vertices[wall1_min_faces[0].vertices[0]].co,
self.target_coordinate,
self.pos_x,
)
)
ef2_distance = abs(
mathutils.geometry.distance_point_to_plane(
self.wall1_matrix @ self.wall1.data.vertices[wall1_max_faces[0].vertices[0]].co,
self.target_coordinate,
self.neg_x,
)
)
if ef1_distance < ef2_distance:
self.project_end_faces_to_target(wall1_min_faces)
else:
self.project_end_faces_to_target(wall1_max_faces)
self.recalculate_origins()
# A T-junction is an ordered operation where a single end of wall1 is joined
# to wall2 if possible (i.e. walls aren't parallel). Wall2 is not modified.
# First, wall1 end faces are identified. We attempt to project an end face
# at both ends to a front face of wall2. We then choose the end face that
# has the shortest projection distance, and project it.
def join_T(self):
self._join_T()
self.recalculate_origins()
def _join_T(self):
wall1_min_faces, wall1_max_faces = self.get_wall_end_faces(self.wall1)
wall2_end_faces1, wall2_end_faces2 = self.get_wall_end_faces(self.wall2)
self.wall2_end_faces = wall2_end_faces1 + wall2_end_faces2
ef1_distance, ef1_target_frontface, ef1_target_backface = self.get_projection_target(wall1_min_faces, 1)
ef2_distance, ef2_target_frontface, ef2_target_backface = self.get_projection_target(wall1_max_faces, 2)
# Large distances probably means rounding issues which lead to very long projections
if ef1_distance and ef1_distance > 50:
ef1_distance = None
if ef2_distance and ef2_distance > 50:
ef2_distance = None
# Project only the end faces that are closer to their target
if ef1_distance and ef2_distance is None:
self.project_end_faces(wall1_min_faces, ef1_target_frontface, ef1_target_backface)
return (wall1_min_faces, ef1_target_frontface, ef1_target_backface)
elif ef2_distance and ef1_distance is None:
self.project_end_faces(wall1_max_faces, ef2_target_frontface, ef2_target_backface)
return (wall1_max_faces, ef2_target_frontface, ef2_target_backface)
elif ef1_distance is None and ef2_distance is None:
return (None, None, None) # Life is short. BIM is hard.
elif ef1_distance < ef2_distance:
self.project_end_faces(wall1_min_faces, ef1_target_frontface, ef1_target_backface)
return (wall1_min_faces, ef1_target_frontface, ef1_target_backface)
else:
self.project_end_faces(wall1_max_faces, ef2_target_frontface, ef2_target_backface)
return (wall1_max_faces, ef2_target_frontface, ef2_target_backface)
# An L-junction is ordered operation where a single end of wall1 is joined
# to the backface of a side of wall2, and then a single end of wall2 is
# joined back to wall1 as a regular T-junction.
def join_L(self):
self.should_project_to_frontface = False
self._join_T()
self.swap_walls()
self.should_project_to_frontface = True
self._join_T()
self.recalculate_origins()
# A V-junction is an unordered operation where wall1 is joined to wall2,
# then vice versa. First, we do a T-junction from wall1 to wall2, then vice
# versa. This creates a junction where the inner vertices of the mitre joint
# touches, but the outer vertices do not. So, we just loop through the end
# point vertices of each wall, find outer vertices (i.e. vertices that don't
# touch the other wall), then continue projecting those to the back face of
# the other wall.
def join_V(self):
wall2_end_faces, wall2_target_frontface, wall2_target_backface = self._join_T()
self.swap_walls()
wall1_end_faces, wall1_target_frontface, wall1_target_backface = self._join_T()
for face in wall1_end_faces or []:
for v in face.vertices:
global_co = self.wall1_matrix @ self.wall1.data.vertices[v].co
if self.wall2.closest_point_on_mesh(self.wall2_matrix.inverted() @ global_co, distance=0.001)[0]:
continue # Vertex is already coincident with other wall, do not mitre
target_face_center = self.wall2_matrix @ wall1_target_backface.center
target_face_normal = (self.wall2_matrix.to_quaternion() @ wall1_target_backface.normal).normalized()
self.project_vertex(v, target_face_center, target_face_normal, self.wall1, self.wall1_matrix)
self.swap_walls()
for face in wall2_end_faces or []:
for v in face.vertices:
global_co = self.wall1_matrix @ self.wall1.data.vertices[v].co
if self.wall2.closest_point_on_mesh(self.wall2_matrix.inverted() @ global_co, distance=0.001)[0]:
continue # Vertex is already coincident with other wall, do not mitre
target_face_center = self.wall2_matrix @ wall2_target_backface.center
target_face_normal = (self.wall2_matrix.to_quaternion() @ wall2_target_backface.normal).normalized()
self.project_vertex(v, target_face_center, target_face_normal, self.wall1, self.wall1_matrix)
self.recalculate_origins()
def recalculate_origins(self):
bpy.context.view_layer.update()
recalculate_dumb_wall_origin(self.wall1)
if self.wall2:
recalculate_dumb_wall_origin(self.wall2)
def swap_walls(self):
self.wall1, self.wall2 = self.wall2, self.wall1
self.initialise_convenience_variables()
def project_end_faces(self, end_faces, target_frontface, target_backface):
target_face = target_frontface if self.should_project_to_frontface else target_backface
target_face_center = self.wall2_matrix @ target_face.center
target_face_normal = (self.wall2_matrix.to_quaternion() @ target_face.normal).normalized()
for end_face in end_faces:
for v in end_face.vertices:
self.project_vertex(v, target_face_center, target_face_normal, self.wall1, self.wall1_matrix)
def project_vertex(self, v, target_face_center, target_face_normal, wall, wall_matrix):
original_point = wall_matrix @ wall.data.vertices[v].co
point = mathutils.geometry.intersect_line_plane(
original_point,
(original_point) + self.pos_x,
target_face_center,
target_face_normal,
)
if not point or (point - original_point).length > 50:
return
local_point = wall_matrix.inverted() @ point
wall.data.vertices[v].co = local_point
def project_end_faces_to_target(self, end_faces):
for end_face in end_faces:
for v in end_face.vertices:
vertex = self.wall1_matrix @ self.wall1.data.vertices[v].co
self.wall1.data.vertices[v].co = self.wall1_matrix.inverted() @ mathutils.geometry.intersect_line_plane(
vertex, vertex + self.pos_x, self.target_coordinate, self.pos_x
)
# A projection target face is a side face on the target wall that has a
# significant local Y component to its normal (i.e. is not pointing up or
# down or something). In addition, its plane must intersect with the
# projection vector of an end face. Finally, the projection vector and the
# normal of the target face must not be acute.
def get_projection_target(self, end_faces, which_end):
if not end_faces:
return (None, None, None)
# Get a single end face as a sample.
f1 = end_faces[0]
f1_center = self.wall1_matrix @ f1.center
if which_end == 1:
outwards = self.neg_x
inwards = self.pos_x
elif which_end == 2:
outwards = self.pos_x
inwards = self.neg_x
distance = None
target_frontface = None
target_backface = None
for f2 in self.wall2.data.polygons:
if abs(f2.normal.y) < 0.75:
continue # Probably not a side wall
if f2 in self.wall2_end_faces:
continue
# Can we project the end face to the target face?
f2_center = self.wall2_matrix @ f2.center
f1_center_offset_x = f1_center + outwards
f2_normal = (self.wall2_matrix.to_quaternion() @ f2.normal).normalized()
point = mathutils.geometry.intersect_line_plane(
f1_center,
f1_center_offset_x,
f2_center,
f2_normal,
)
if not point:
continue # We can't project to the face at all
intersection_point, signed_distance = mathutils.geometry.intersect_point_line(
point, f1_center, f1_center_offset_x
)
raycast_direction = outwards if signed_distance > 0 else inwards
if raycast_direction == outwards and f2_normal.angle(raycast_direction) < math.pi / 2:
target_backface = f2 # f2 is on the wrong side of the wall
elif raycast_direction == inwards and f2_normal.angle(raycast_direction) > math.pi / 2:
target_backface = f2 # f2 is on the wrong side of the wall
else:
target_frontface = f2
distance = (point - f1_center).length
if distance is not None and target_frontface is not None and target_backface is not None:
return (distance, target_frontface, target_backface)
return (None, None, None)
# An end face is a set of faces that represents either one end of the wall or
# the other. There is typically only 1 quad or 2 tris for each end.
# An end face is defined as having at least one vertex on either extreme
# X-axis, and a non-insignificant X component of its face normal
def get_wall_end_faces(self, wall):
min_faces = []
max_faces = []
min_x = min([v[0] for v in wall.bound_box])
max_x = max([v[0] for v in wall.bound_box])
for f in wall.data.polygons:
if abs(f.normal.x) < 0.1:
continue
end_face_index = self.get_wall_face_end(wall, f, min_x, max_x)
if end_face_index == 1:
min_faces.append(f)
elif end_face_index == 2:
max_faces.append(f)
return (min_faces, max_faces)
# 1 is the leftmost (minimum local X axis) end, and 2 is the rightmost end
def get_wall_face_end(self, wall, face, min_x, max_x):
for v in face.vertices:
if wall.data.vertices[v].co.x == min_x:
return 1
if wall.data.vertices[v].co.x == max_x:
return 2
class DumbWallGenerator:
def __init__(self, relating_type):
self.relating_type = relating_type
def generate(self):
self.file = IfcStore.get_file()
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(IfcStore.get_file())
thicknesses = []
for rel in self.relating_type.HasAssociations:
if rel.is_a("IfcRelAssociatesMaterial"):
material = rel.RelatingMaterial
if material.is_a("IfcMaterialLayerSet"):
thicknesses = [l.LayerThickness for l in material.MaterialLayers]
break
if not sum(thicknesses):
return
self.collection = bpy.context.view_layer.active_layer_collection.collection
self.collection_obj = bpy.data.objects.get(self.collection.name)
self.width = sum(thicknesses) * unit_scale
self.height = 3
self.length = 1
self.rotation = 0
self.location = Vector((0, 0, 0))
if self.has_sketch():
return self.derive_from_sketch()
return self.derive_from_cursor()
def has_sketch(self):
return (
bpy.context.scene.grease_pencil
and len(bpy.context.scene.grease_pencil.layers) == 1
and bpy.context.scene.grease_pencil.layers[0].active_frame.strokes
)
def derive_from_sketch(self):
objs = []
strokes = []
layer = bpy.context.scene.grease_pencil.layers[0]
for stroke in layer.active_frame.strokes:
if len(stroke.points) == 1:
continue
data = self.create_wall_from_2_points((stroke.points[0].co, stroke.points[-1].co))
if data:
strokes.append(data)
objs.append(data["obj"])
if len(objs) < 2:
return objs
l_joins = set()
for stroke in strokes:
if not stroke["obj"]:
continue
for stroke2 in strokes:
if stroke2 == stroke or not stroke2["obj"]:
continue
if self.has_nearby_ends(stroke, stroke2):
wall_join = "-JOIN-".join(sorted([stroke["obj"].name, stroke2["obj"].name]))
if wall_join not in l_joins:
l_joins.add(wall_join)
DumbWallJoiner(stroke["obj"], stroke2["obj"]).join_L()
elif self.has_end_near_stroke(stroke, stroke2):
DumbWallJoiner(stroke["obj"], stroke2["obj"]).join_T()
bpy.context.scene.grease_pencil.layers.remove(layer)
return objs
def create_wall_from_2_points(self, coords):
direction = coords[1] - coords[0]
length = direction.length
if length < 0.1:
return
data = {"coords": coords}
# Round to nearest 50mm (yes, metric for now)
self.length = 0.05 * round(length / 0.05)
self.rotation = math.atan2(direction[1], direction[0])
# Round to nearest 5 degrees
nearest_degree = (math.pi / 180) * 5
self.rotation = nearest_degree * round(self.rotation / nearest_degree)
self.location = coords[0]
data["obj"] = self.create_wall()
return data
def has_end_near_stroke(self, stroke, stroke2):
point, distance = mathutils.geometry.intersect_point_line(stroke["coords"][0], *stroke2["coords"])
if distance > 0 and distance < 1 and self.is_near(point, stroke["coords"][0]):
return True
point, distance = mathutils.geometry.intersect_point_line(stroke["coords"][1], *stroke2["coords"])
if distance > 0 and distance < 1 and self.is_near(point, stroke["coords"][1]):
return True
def has_nearby_ends(self, stroke, stroke2):
return (
self.is_near(stroke["coords"][0], stroke2["coords"][0])
or self.is_near(stroke["coords"][0], stroke2["coords"][1])
or self.is_near(stroke["coords"][1], stroke2["coords"][0])
or self.is_near(stroke["coords"][1], stroke2["coords"][1])
)
def is_near(self, point1, point2):
return (point1 - point2).length < 0.1
def derive_from_cursor(self):
self.location = bpy.context.scene.cursor.location
if self.collection:
for sibling_obj in self.collection.objects:
if not isinstance(sibling_obj.data, bpy.types.Mesh):
continue
if "IfcWall" not in sibling_obj.name:
continue
local_location = sibling_obj.matrix_world.inverted() @ self.location
raycast = sibling_obj.closest_point_on_mesh(local_location, distance=0.01)
if not raycast[0]:
continue
for face in sibling_obj.data.polygons:
if (
abs(face.normal.y) >= 0.75
and abs(mathutils.geometry.distance_point_to_plane(local_location, face.center, face.normal))
< 0.01
):
# Rotate the wall in the direction of the face normal
normal = (sibling_obj.matrix_world.to_quaternion() @ face.normal).normalized()
self.rotation = math.atan2(normal[1], normal[0])
break
return self.create_wall()
def create_wall(self):
verts = [
Vector((0, self.width, 0)),
Vector((0, 0, 0)),
Vector((0, self.width, self.height)),
Vector((0, 0, self.height)),
Vector((self.length, self.width, 0)),
Vector((self.length, 0, 0)),
Vector((self.length, self.width, self.height)),
Vector((self.length, 0, self.height)),
]
faces = [
[1, 3, 2, 0],
[4, 6, 7, 5],
[1, 0, 4, 5],
[3, 7, 6, 2],
[0, 2, 6, 4],
[1, 5, 7, 3],
]
mesh = bpy.data.meshes.new(name="Wall")
mesh.from_pydata(verts, [], faces)
ifc_classes = ifcopenshell.util.type.get_applicable_entities(self.relating_type.is_a(), self.file.schema)
# Standard cases are deprecated, so let's cull them
ifc_class = [c for c in ifc_classes if "StandardCase" not in c][0]
obj = bpy.data.objects.new(tool.Model.generate_occurrence_name(self.relating_type, ifc_class), mesh)
obj.location = self.location
obj.rotation_euler[2] = self.rotation
if self.collection_obj and self.collection_obj.BIMObjectProperties.ifc_definition_id:
obj.location[2] = self.collection_obj.location[2]
self.collection.objects.link(obj)
bpy.ops.bim.assign_class(
obj=obj.name,
ifc_class=ifc_class,
ifc_representation_class="IfcExtrudedAreaSolid/IfcArbitraryClosedProfileDef",
)
blenderbim.core.type.assign_type(tool.Ifc, tool.Type, element=tool.Ifc.get_entity(obj), type=self.relating_type)
element = self.file.by_id(obj.BIMObjectProperties.ifc_definition_id)
pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="EPset_Parametric")
ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Engine": "BlenderBIM.DumbLayer2"})
MaterialData.load(self.file)
try:
obj.select_set(True)
except RuntimeError:
def msg(self, context):
txt = "The created object could not be assigned to a collection. "
txt += "Has any IfcSpatialElement been deleted?"
self.layout.label(text=txt)
bpy.context.window_manager.popup_menu(msg, title="Error", icon="ERROR")
return obj
def generate_axis(usecase_path, ifc_file, settings):
axis_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Axis", "GRAPH_VIEW")
if not axis_context:
return
obj = settings["blender_object"]
product = ifc_file.by_id(obj.BIMObjectProperties.ifc_definition_id)
parametric = ifcopenshell.util.element.get_psets(product).get("EPset_Parametric")
if not parametric or parametric["Engine"] != "BlenderBIM.DumbLayer2":
return
old_axis = ifcopenshell.util.representation.get_representation(product, "Model", "Axis", "GRAPH_VIEW")
if settings["context"].ContextType == "Model" and getattr(settings["context"], "ContextIdentifier") == "Body":
if old_axis:
blenderbim.core.geometry.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_axis)
new_settings = settings.copy()
new_settings["context"] = axis_context
mesh = bpy.data.meshes.new("Temporary Axis")
start = Vector(obj.bound_box[0])
end = Vector(obj.bound_box[4])
mesh.from_pydata([start, end], [(0, 1)], [])
new_settings["geometry"] = mesh
new_axis = ifcopenshell.api.run(
"geometry.add_representation", ifc_file, should_run_listeners=False, **new_settings
)
ifcopenshell.api.run(
"geometry.assign_representation",
ifc_file,
should_run_listeners=False,
**{"product": product, "representation": new_axis}
)
bpy.data.meshes.remove(mesh)
def calculate_quantities(usecase_path, ifc_file, settings):
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
obj = settings["blender_object"]
product = ifc_file.by_id(obj.BIMObjectProperties.ifc_definition_id)
parametric = ifcopenshell.util.element.get_psets(product).get("EPset_Parametric")
if not parametric or "Engine" not in parametric or parametric["Engine"] != "BlenderBIM.DumbLayer2":
return
qto = ifcopenshell.api.run(
"pset.add_qto", ifc_file, should_run_listeners=False, product=product, name="Qto_WallBaseQuantities"
)
length = obj.dimensions[0] / unit_scale
width = obj.dimensions[1] / unit_scale
height = obj.dimensions[2] / unit_scale
bm_gross = bmesh.new()
bm_gross.from_mesh(obj.data)
bm_gross.faces.ensure_lookup_table()
bm_net = bmesh.new()
depsgraph = bpy.context.evaluated_depsgraph_get()
evaluated_mesh = obj.evaluated_get(depsgraph).data
bm_net.from_mesh(evaluated_mesh)
bm_net.faces.ensure_lookup_table()
gross_footprint_area = sum([f.calc_area() for f in bm_gross.faces if f.normal.z < -0.9])
net_footprint_area = sum([f.calc_area() for f in bm_net.faces if f.normal.z < -0.9])
gross_side_area = sum([f.calc_area() for f in bm_gross.faces if f.normal.y > 0.9])
net_side_area = sum([f.calc_area() for f in bm_net.faces if f.normal.y > 0.9])
gross_volume = bm_gross.calc_volume()
net_volume = bm_net.calc_volume()
bm_gross.free()
bm_net.free()
ifcopenshell.api.run(
"pset.edit_qto",
ifc_file,
should_run_listeners=False,
qto=qto,
properties={
"Length": round(length, 2),
"Width": round(width, 2),
"Height": round(height, 2),
"GrossFootprintArea": round(gross_footprint_area, 2),
"NetFootprintArea": round(net_footprint_area, 2),
"GrossSideArea": round(gross_side_area, 2),
"NetSideArea": round(net_side_area, 2),
"GrossVolume": round(gross_volume, 2),
"NetVolume": round(net_volume, 2),
},
)
PsetData.load(ifc_file, obj.BIMObjectProperties.ifc_definition_id)
class DumbWallPlaner:
def regenerate_from_layer(self, usecase_path, ifc_file, settings):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
layer = settings["layer"]
thickness = settings["attributes"].get("LayerThickness")
if thickness is None:
return
for layer_set in layer.ToMaterialLayerSet:
total_thickness = sum([l.LayerThickness for l in layer_set.MaterialLayers])
if not total_thickness:
continue
for inverse in ifc_file.get_inverse(layer_set):
if not inverse.is_a("IfcMaterialLayerSetUsage") or inverse.LayerSetDirection != "AXIS2":
continue
if ifc_file.schema == "IFC2X3":
for rel in ifc_file.get_inverse(inverse):
if not rel.is_a("IfcRelAssociatesMaterial"):
continue
for element in rel.RelatedObjects:
self.change_thickness(element, thickness)
else:
for rel in inverse.AssociatedTo:
for element in rel.RelatedObjects:
self.change_thickness(element, thickness)
def regenerate_from_type(self, usecase_path, ifc_file, settings):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
new_material = ifcopenshell.util.element.get_material(settings["relating_type"])
if not new_material or not new_material.is_a("IfcMaterialLayerSet"):
return
new_thickness = sum([l.LayerThickness for l in new_material.MaterialLayers])
material = ifcopenshell.util.element.get_material(settings["related_object"])
relating_type = settings["relating_type"]
if hasattr(relating_type, "HasPropertySets"):
psets = relating_type.HasPropertySets
if psets is not None:
for pset in psets:
if hasattr(pset, "HasProperties"):
pset_props = pset.HasProperties
if pset_props is not None:
for prop in pset_props:
if prop.Name == "LayerSetDirection":
if hasattr(prop, "NominalValue"):
nominal_value = prop.NominalValue
if hasattr(nominal_value, "wrappedValue"):
if nominal_value.wrappedValue == "AXIS3":
return
if material and material.is_a("IfcMaterialLayerSetUsage") and material.LayerSetDirection == "AXIS2":
self.change_thickness(settings["related_object"], new_thickness)
def change_thickness(self, element, thickness):
obj = IfcStore.get_element(element.id())
if not obj:
return
delta_thickness = (thickness * self.unit_scale) - obj.dimensions.y
if round(delta_thickness, 2) == 0:
return
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 1, verts=bm.verts, edges=bm.edges)
min_face, max_face = self.get_wall_end_faces(obj, bm)
verts_to_move = []
verts_to_move.extend(self.thicken_face(min_face, delta_thickness))
verts_to_move.extend(self.thicken_face(max_face, delta_thickness))
for vert_to_move in verts_to_move:
vert_to_move["vert"].co += vert_to_move["vector"]
bm.to_mesh(obj.data)
obj.data.update()
bm.free()
IfcStore.edited_objs.add(obj)
def thicken_face(self, face, delta_thickness):
slide_magnitude = abs(delta_thickness)
results = []
for vert in face.verts:
slide_vector = None
for edge in vert.link_edges:
other_vert = edge.verts[1] if edge.verts[0] == vert else edge.verts[0]
if delta_thickness > 0:
potential_slide_vector = (vert.co - other_vert.co).normalized()
if potential_slide_vector.y < 0:
continue
else:
potential_slide_vector = (other_vert.co - vert.co).normalized()
if potential_slide_vector.y > 0:
continue
if abs(potential_slide_vector.x) > 0.9 or abs(potential_slide_vector.z) > 0.9:
continue
slide_vector = potential_slide_vector
break
if not slide_vector:
continue
slide_vector *= slide_magnitude / abs(slide_vector.y)
results.append({"vert": vert, "vector": slide_vector})
return results
# An end face is a quad that is on one end of the wall or the other. It must
# have at least one vertex on either extreme X-axis, and a non-insignificant
# X component of its face normal
def get_wall_end_faces(self, wall, bm):
min_face = None
max_face = None
min_x = min([v[0] for v in wall.bound_box])
max_x = max([v[0] for v in wall.bound_box])
bm.faces.ensure_lookup_table()
for f in bm.faces:
for v in f.verts:
if v.co.x == min_x and abs(f.normal.x) > 0.1:
min_face = f
elif v.co.x == max_x and abs(f.normal.x) > 0.1:
max_face = f
if min_face and max_face:
break
return min_face, max_face