bim.mep_add_bend

Added operator to create bend fittings between objects, it's still work in progress and now it only supports rectangular profiles.

Small demo - https://imgur.com/a/vM7hnJa
This commit is contained in:
Andrej730
2023-08-28 15:25:10 +05:00
parent 3aa72ae74a
commit cfac835607
6 changed files with 578 additions and 80 deletions
@@ -179,6 +179,7 @@ classes = (
roof.SetGableRoofEdgeAngle,
mep.MEPAddObstruction,
mep.MEPAddTransition,
mep.MEPAddBend,
)
addon_keymaps = []
+335 -27
View File
@@ -35,7 +35,7 @@ import blenderbim.core.type
import blenderbim.core.root
import blenderbim.core.geometry
import blenderbim.tool as tool
from math import pi, degrees, radians
from math import pi, degrees, radians, sin, cos, asin
from copy import copy
from mathutils import Vector, Matrix
from ifcopenshell.util.shape_builder import ShapeBuilder
@@ -201,6 +201,7 @@ class FitFlowSegments(bpy.types.Operator, tool.Ifc.Operator):
is_on_axis2 = tool.Cad.is_point_on_edge(intersect2, axis2)
if not is_on_axis1 and not is_on_axis2:
fitting_type = "BEND"
bpy.ops.bim.mep_add_bend()
elif is_on_axis1 and is_on_axis2:
fitting_type = "CROSS"
else:
@@ -644,32 +645,40 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator):
return {"CANCELLED"}
# TODO: support different profiles rotation by local Z
rotation_difference_z = start_object.matrix_world.to_quaternion().rotation_difference(end_object.matrix_world.to_quaternion()).to_euler().z
# check rotation difference
end_object_rotation = end_object.matrix_world.to_quaternion()
rotation_difference_z = (
start_object.matrix_world.to_quaternion().rotation_difference(end_object_rotation).to_euler().z
)
def is_multiple_of_pi(value):
n = round(value / pi)
return tool.Cad.is_x(abs(value - n * pi), 0)
if not is_multiple_of_pi(rotation_difference_z):
self.report({"ERROR"}, f"There is some rotation difference between profiles by local Z axis: {round(degrees(rotation_difference_z))} deg, this kind of transition is not yet supported.")
self.report(
{"ERROR"},
"There is some rotation difference between profiles by local Z axis: "
f"{round(degrees(rotation_difference_z))} deg, this kind of transition is not yet supported.",
)
return {"CANCELLED"}
# setup start / end points
start_segment_data = MEPGenerator().get_segment_data(start_element)
end_segment_data = MEPGenerator().get_segment_data(end_element)
end_port = end_segment_data["start_port"]
start_port = start_segment_data["end_port"]
points_ports_map = {
start_segment_data["start_point"]: start_segment_data["start_port"],
start_segment_data["end_point"]: start_segment_data["end_port"],
end_segment_data["start_point"]: end_segment_data["start_port"],
end_segment_data["end_point"]: end_segment_data["end_port"],
}
# transition points
start_point, end_point = tool.Cad.closest_points(
(start_point, end_point), (first_segment_start, second_segment_end) = tool.Cad.closest_points(
(start_segment_data["start_point"], start_segment_data["end_point"]),
(end_segment_data["start_point"], end_segment_data["end_point"]),
)
start_port = points_ports_map[start_point]
end_port = points_ports_map[end_point]
# figure profile offset
base_transition_dir = keep_only_z_axis(end_point - start_point).normalized()
@@ -694,18 +703,6 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator):
start_object_rotation @ (profile_offset * si_conversion).to_3d() if profile_offset else V(0, 0, 0)
)
# will need entire_length to check that transition length fill fit
first_segment_start, second_segment_end = [
p
for p in (
start_segment_data["start_point"],
start_segment_data["end_point"],
end_segment_data["start_point"],
end_segment_data["end_point"],
)
if p not in (start_point, end_point)
]
def get_segments_length():
start_dir = (start_point - first_segment_start).normalized()
segments_vector = second_segment_end - first_segment_start
@@ -716,8 +713,6 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator):
# can't rely on (end_point-start_point) here because
# transition might change the segments length and therefore direction will be changed
segments_dir = (start_point - first_segment_start).normalized()
start_port = points_ports_map[start_point]
end_port = points_ports_map[end_point]
# add transition representation
builder = ShapeBuilder(ifc_file)
@@ -752,6 +747,8 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator):
# adjust the segments
end_object_rotation = end_object.matrix_world.to_quaternion()
end_object_z_basis = end_object_rotation.to_matrix().col[2] # z basis vector
# TODO: do it beforehand, as with bends
if tool.Cad.is_x(start_object_z_basis.dot(transition_dir), 1):
start_connection = "ATEND"
else:
@@ -768,13 +765,11 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator):
[start_element, end_element], [start_port, end_port], "TRANSITION"
)
transition_type = fitting_data["fitting_type"] if fitting_data else None
start_port_match = fitting_data["start_port_match"] if fitting_data else True
if transition_type:
# TODO: handle the case without creating a representation in the first place?
ifcopenshell.api.run("geometry.remove_representation", ifc_file, representation=rep)
start_port_match = fitting_data["start_port_match"] if fitting_data else True
# create new fitting type if nothing is compatible
if not transition_type:
else: # create new fitting type if nothing is compatible
mesh = bpy.data.meshes.new("Transition")
obj = bpy.data.objects.new("Transition", mesh)
transition_type = blenderbim.core.root.assign_class(
@@ -825,3 +820,316 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator):
tool.Ifc.run("system.connect_port", port1=ports[1], port2=end_port, direction="NOTDEFINED")
return {"FINISHED"}
class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.mep_add_bend"
bl_label = "Add Bend"
bl_description = "Adds a bend between two MEP elements. Elements are either provided by ID or selected in Blender"
bl_options = {"REGISTER", "UNDO"}
start_length: bpy.props.FloatProperty(
name="Start Length", description="Bend start length in SI units", default=0.1, subtype="DISTANCE"
)
end_length: bpy.props.FloatProperty(
name="End Length", description="Bend end length in SI units", default=0.1, subtype="DISTANCE"
)
start_segment_id: bpy.props.IntProperty(name="Start Segment Element ID", default=0)
end_segment_id: bpy.props.IntProperty(name="End Segment Element ID", default=0)
radius: bpy.props.FloatProperty(
"Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE"
)
def _execute(self, context):
start_element, end_element = None, None
ifc_file = tool.Ifc.get()
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
self.start_length, self.end_length = 0, 0
if not (self.start_length == 0 and self.end_length == 0):
self.report({"ERROR"}, f"Only zero lengths are now supported.")
return {"CANCELLED"}
if self.start_segment_id and self.end_segment_id:
start_element = ifc_file.by_id(self.start_segment_id)
end_element = ifc_file.by_id(self.end_segment_id)
start_object = tool.Ifc.get_object(start_element)
end_object = tool.Ifc.get_object(end_element)
elif len(context.selected_objects) == 2:
start_object = context.active_object
end_object = next(o for o in context.selected_objects if o != context.active_object)
start_element = tool.Ifc.get_entity(start_object)
end_element = tool.Ifc.get_entity(end_object)
if not start_element or not end_element:
self.report({"ERROR"}, f"Two IFC elements should be selected for the bend.")
return {"CANCELLED"}
else:
self.report({"ERROR"}, f"Two IFC elements should be provided for the bend.")
return {"CANCELLED"}
# check rotation difference
def rotation_difference_check():
end_object_rotation = end_object.matrix_world.to_quaternion()
rotation_difference = start_object.matrix_world.to_quaternion().rotation_difference(end_object_rotation).to_euler()
def is_multiple_of_pi(value):
n = round(value / pi)
return tool.Cad.is_x(abs(value - n * pi), 0)
if not is_multiple_of_pi(rotation_difference.z):
error_msg = (
"There is some rotation difference between profiles by local Z axis: "
f"{round(degrees(rotation_difference.z))} deg, adding a bend is not possible."
)
return error_msg
if error_msg := rotation_difference_check():
self.report({"ERROR"}, error_msg)
return {"CANCELLED"}
# check segments types
def types_check():
start_type = ifcopenshell.util.element.get_type(start_element)
end_type = ifcopenshell.util.element.get_type(end_element)
if not start_type or not end_type:
return False
return start_type == end_type
if not types_check():
self.report(
{"ERROR"},
"Segments types do not match "
"or one of the segments doesn't have type which is required for a bend.",
)
return {"CANCELLED"}
# TODO: support circular profiles
profile = tool.Model.get_flow_segment_profile(start_element)
if not profile.is_a("IfcRectangleProfileDef"):
self.report(
{
"ERROR",
"For now Only IfcRectangleProfileDef profiles supported for a bend, "
f"the segments are {profile.is_a()}",
}
)
return {"CANCELLED"}
def get_dim(profile):
if profile.is_a("IfcRectangleProfileDef"):
return V(profile.XDim / 2, profile.YDim / 2)
elif profile.is_a("IfcCircleProfileDef"):
return V(profile.Radius, profile.Radius)
return None
# setup start / end points
start_object_rotation = start_object.matrix_world.to_quaternion().to_matrix()
start_segment_data = MEPGenerator().get_segment_data(start_element)
end_segment_data = MEPGenerator().get_segment_data(end_element)
points_ports_map = {
start_segment_data["start_point"]: start_segment_data["start_port"],
start_segment_data["end_point"]: start_segment_data["end_port"],
end_segment_data["start_point"]: end_segment_data["start_port"],
end_segment_data["end_point"]: end_segment_data["end_port"],
}
(start_point, end_point), (first_segment_start, second_segment_end) = tool.Cad.closest_points(
(start_segment_data["start_point"], start_segment_data["end_point"]),
(end_segment_data["start_point"], end_segment_data["end_point"]),
)
start_port = points_ports_map[start_point]
end_port = points_ports_map[end_point]
start_point_on_origin = start_point == start_segment_data["start_point"]
start_connection = "ATSTART" if start_point_on_origin else "ATEND"
start_segment_sign = -1 if start_point_on_origin else 1
end_point_on_origin = end_point == end_segment_data["start_point"]
end_connection = "ATSTART" if end_point_on_origin else "ATEND"
end_segment_sign = -1 if end_point_on_origin else 1
profile_dim = get_dim(profile) * si_conversion
# TODO: profile offset may need to be flipped (check transition code)
to_start_object_space = start_object_rotation.inverted()
profile_offset = (to_start_object_space @ end_point) - (to_start_object_space @ start_point)
def check_for_double_bends():
# The theory is To avoid double bends, the profile offset should occur along only two axes:
# 1) The local Z-axis of the start segment
# 2) One of the lateral axes (either X or Y)
#
# Double bend required when:
# - there are 2 or 0 lateral axes involved
# - offset appear by the non-lateral axis
#
# NOTE: some double bends are only possible for square profiles:
# https://i.imgur.com/ZhdGbEp.png
z_axis_end_object = end_object.matrix_world.col[2].normalized().to_3d()
z_axis_end_object_local = to_start_object_space @ z_axis_end_object
lateral_axes = [i for i in range(2) if not tool.Cad.is_x(z_axis_end_object_local[i], 0)]
if len(lateral_axes) != 1:
return (
None,
f"For now only one lateral axis is supported for a bend (double bends not supported). Found lateral axes: {len(lateral_axes)}.",
)
non_lateral_axis = 0 if lateral_axes[0] == 1 else 1
non_lateral_axis_offset = profile_offset[non_lateral_axis]
if not tool.Cad.is_x(non_lateral_axis_offset, 0):
return (
None,
"For now offset by non-lateral axis is not supported for a bend (double bends not supported).\n"
f"Detected an offset of {round(non_lateral_axis_offset, 5)} along the local axis {'XY'[non_lateral_axis]} when lateral axis is {'XY'[lateral_axes[0]]}.",
)
return lateral_axes[0], None
lateral_axis, error_msg = check_for_double_bends()
if error_msg:
self.report({"ERROR"}, error_msg)
return {"CANCELLED"}
O = V(0, 0, 0)
get_z_basis = lambda o: o.matrix_world.col[2].normalized().to_3d()
angle = tool.Cad.angle_edges((get_z_basis(start_object), O), (get_z_basis(end_object), O))
lateral_sign = tool.Cad.sign(profile_offset[lateral_axis])
radial_offset = V(0, 0, 0)
ref_point_radius = self.radius + profile_dim[lateral_axis]
radial_offset[lateral_axis] = ref_point_radius * (1 - cos(angle)) * lateral_sign
radial_offset.z = ref_point_radius * sin(angle)
def get_segments_extend():
end_segment_z_local = to_start_object_space @ get_z_basis(end_object)
segments_intersection = tool.Cad.intersect_edges(
(V(0, 0, 1), V(0, 0, 0)), (profile_offset + end_segment_z_local, profile_offset)
)[0]
curent_start_offset = segments_intersection.length
required_start_offset = abs(radial_offset.z)
current_end_offset = (segments_intersection - profile_offset).length
required_end_offset = abs(radial_offset[lateral_axis])
start_extend = curent_start_offset - required_start_offset
end_extend = current_end_offset - required_end_offset
return start_extend, end_extend
def check_new_segment_length(start_point, end_point, extend_point):
"""Check if segment is placed too near to the bend point.
The idea is that we can either extend segment toward the bend
but we can shrink it only until it's start.
If the segment is too near it will return offset to fix the problem,
otherwise returns `None`.
"""
base_edge = end_point - start_point
new_edge = extend_point - start_point
projection = new_edge.dot(base_edge.normalized())
if projection < 0 or tool.Cad.is_x(projection, 0):
return projection
return None
# adjust segments to fit the radius and angle
start_segment_extend, end_segment_extend = get_segments_extend()
start_segment_extend_point = start_point + start_segment_sign * start_segment_extend * get_z_basis(start_object)
projection = check_new_segment_length(first_segment_start, start_point, start_segment_extend_point)
if projection is not None:
self.report(
{"ERROR"},
f"Start segment starts too near to the bend, need to offset it atleast by {round(projection, 3)} m.",
)
return {"ERROR"}
end_segment_extend_point = end_point + end_segment_sign * end_segment_extend * get_z_basis(end_object)
projection = check_new_segment_length(second_segment_end, end_point, end_segment_extend_point)
if projection is not None:
self.report(
{"ERROR"},
f"End segment starts too near to the bend, need to offset it atleast by {round(projection, 3)} m.",
)
return {"ERROR"}
DumbProfileJoiner().join_E(start_object, start_segment_extend_point, start_connection)
DumbProfileJoiner().join_E(end_object, end_segment_extend_point, end_connection)
context.view_layer.update() # update matrices
builder = ShapeBuilder(ifc_file)
rep, bend_data = builder.mep_bend_shape(
start_element,
self.start_length / si_conversion,
self.end_length / si_conversion,
angle,
self.radius / si_conversion,
profile_offset / si_conversion,
)
bpy.ops.bim.create_shape_from_step_id(step_id=rep.id(), should_include_curves=True)
# find the compatible fitting type
fitting_data = MEPGenerator().get_compatible_fitting_type(
[start_element, end_element], [start_port, end_port], "BEND"
)
bend_type = fitting_data["fitting_type"] if fitting_data else None
start_port_match = fitting_data["start_port_match"] if fitting_data else True
if bend_type:
# TODO: handle the case without creating a representation in the first place?
ifcopenshell.api.run("geometry.remove_representation", ifc_file, representation=rep)
else: # create new fitting type if nothing is compatible
mesh = bpy.data.meshes.new("Bend")
obj = bpy.data.objects.new("Bend", mesh)
bend_type = blenderbim.core.root.assign_class(
tool.Ifc,
tool.Collector,
tool.Root,
obj=obj,
ifc_class=MEPGenerator().get_mep_element_class_name(start_element, "FittingType"),
predefined_type="BEND",
should_add_representation=False,
)
body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
tool.Model.replace_object_ifc_representation(body, obj, rep)
pset = ifcopenshell.api.run("pset.add_pset", tool.Ifc.get(), product=bend_type, name="BBIM_Fitting")
ifcopenshell.api.run(
"pset.edit_pset",
tool.Ifc.get(),
pset=pset,
properties={"Data": json.dumps(bend_data, default=list)},
)
# NOTE: at this point we loose current blender objects selection
# create transition element
bpy.ops.bim.add_constr_type_instance(relating_type_id=bend_type.id())
fitting_obj = bpy.context.active_object
# adjust fitting object rotation and location
# required since we'll base our `fitting_obj_dir` on this
fitting_obj.matrix_world = start_object.matrix_world
context.view_layer.update()
# depending on fitting direction we may need to flip it or attach it's origin to end segment
# direction can be different depending on:
# - order of the current segments
# - order of the segments that were used with the same fitting type before
direction_match = tool.Cad.are_vectors_equal(get_z_basis(start_object), get_z_basis(fitting_obj))
# if there are no mismatches or everything matches up we don't need to flip the transition
if start_port_match != direction_match:
fitting_obj.matrix_world = start_object.matrix_world @ Matrix.Rotation(radians(180), 4, "X")
fitting_obj.location = start_segment_extend_point if start_port_match else end_segment_extend_point
# add ports and connect them
ports = tool.System.add_ports(fitting_obj, offset_end_port=start_object_rotation @ (radial_offset * V(1, 1, 0)))
if not start_port_match:
start_port, end_port = end_port, start_port
tool.Ifc.run("system.connect_port", port1=ports[0], port2=start_port, direction="NOTDEFINED")
tool.Ifc.run("system.connect_port", port1=ports[1], port2=end_port, direction="NOTDEFINED")
self.report({"INFO"}, f"Success!.. kind of. The angle was {round(bend_data['angle'])}")
return {"FINISHED"}
+39 -14
View File
@@ -35,6 +35,7 @@ import math
import bmesh
import mathutils.geometry
from mathutils import Vector, Matrix, geometry
import itertools
VTX_PRECISION = 1.0e-5
@@ -74,6 +75,8 @@ class Cad:
"""
> takes 2 edges, each as a tuple of two vectors
< returns the potentially signed angle as degrees or radians
NOTE: `signed` expects both edges to be 2D (just as `Vector.angle_signed`)
"""
if signed:
a = (edge1[1] - edge1[0]).angle_signed(edge2[1] - edge2[0])
@@ -248,23 +251,33 @@ class Cad:
return cls.are_edges_parallel((edge2[0], edge1[0]), edge2)
@classmethod
def closest_points(cls, edge1, edge2) -> bool:
def closest_points(cls, edge1, edge2):
"""
closest end points between `edge1` and `edge2`
ensures returned vectors are the exact objects
that were passed to the method with `edge1` and `edge2`
< returns two tuples - two closest points and two other points
first point of each tuple belongs to `edge1` and second to `edge2`
"""
closest end points between `edge1` and `edge2` assuming `edge1` and `edge2` are collinear.
distance_squared = None
closest_points = None
for p1 in edge1:
for p2 in edge2:
cur_line = p2 - p1
cur_distance_squared = cur_line.dot(cur_line)
if distance_squared is None or cur_distance_squared < distance_squared:
closest_points = (p1, p2)
distance_squared = cur_distance_squared
< returns two points, first one belongs to `edge1` and second to `edge2`
"""
direction = (edge1[1] - edge1[0]).normalized()
# Project points onto the line to get scalar values along the direction
points_values = [(p, p.dot(direction)) for p in (edge1 + edge2)]
sorted_points = sorted(points_values, key=lambda el: el[1])
edge1_point = next((p for p, v in sorted_points[1:3] if p in edge1), None)
edge2_point = next((p for p, v in sorted_points[1:3] if p in edge2), None)
return edge1_point, edge2_point
other_points = (
edge1[0] if closest_points[0] == edge1[1] else edge1[1],
edge2[0] if closest_points[1] == edge2[1] else edge2[1],
)
return closest_points, other_points
@classmethod
def find_intersecting_edges(cls, bm, pt, idx1, idx2):
@@ -489,3 +502,15 @@ class Cad:
def is_counter_clockwise_order(cls, A, B, C):
"""whether A-B-C located in counter-clockwise order in 2d space"""
return (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x)
@classmethod
def sign(cls, value):
"""
returns:
0 if cls.is_x(value, 0)) \n
1 if value > 0 \n
-1 if value < 0
"""
if cls.is_x(value, 0):
return 0
return 1 if value > 0 else -1
+2 -1
View File
@@ -42,7 +42,8 @@ class System(blenderbim.core.tool.System):
blenderbim.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
mep_element = tool.Ifc.get_entity(obj)
length = obj.dimensions.z
bbox = tool.Blender.get_object_bounding_box(obj)
length = bbox["min_z"] if tool.Cad.is_x(bbox["max_z"], 0) else bbox["max_z"]
ports = []
if add_start_port:
ports.append(add_port(mep_element, obj.matrix_world @ Matrix()))
+31
View File
@@ -56,3 +56,34 @@ class TestAreEdgesCollinear(NewFile):
(V(0,1,0), V(1,0,1))
)
# fmt: on
class TestClosestPoints(NewFile):
def test_run(self):
# non collinear
edge1 = (V(0, 0, 0), V(1, 0, 0))
edge2 = (V(2, 0, 1), V(2, 0, 2))
assert subject.closest_points(edge1, edge2)[0] == (edge1[1], edge2[0])
# check other points
assert subject.closest_points(edge1, edge2)[1] == (edge1[0], edge2[1])
# collinear
edge1 = (V(0, 0, 0), V(1, 0, 0))
edge2 = (V(3, 0, 0), V(2, 0, 0))
assert subject.closest_points(edge1, edge2)[0] == (edge1[1], edge2[1])
# parallel
edge1 = (V(0, 0, 0), V(1, 0, 0))
edge2 = (V(-5, 0, 0), V(-1, 0, 0))
assert subject.closest_points(edge1, edge2)[0] == (edge1[0], edge2[1])
# overlapping
edge1 = (V(0, 0, 0), V(3, 0, 0))
edge2 = (V(2, 0, 0), V(5, 0, 0))
assert subject.closest_points(edge1, edge2)[0] == (edge1[1], edge2[0])
# edge as a point
edge1 = (V(0, 0, 0), V(0, 0, 0))
edge2 = (V(1, 0, 1), V(2, 0, 2))
assert subject.closest_points(edge1, edge2)[0] == (edge1[0], edge2[0])
@@ -1,5 +1,5 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 @Andrej730
# Copyright (C) 2022, 2023 @Andrej730
#
# This file is part of IfcOpenShell.
#
@@ -19,14 +19,21 @@
import collections
import ifcopenshell
import ifcopenshell.api
from math import cos, sin, pi, tan, radians, degrees, atan, sqrt
from math import cos, sin, pi, tan, radians, degrees, atan, sqrt, ceil
from mathutils import Vector, Matrix
from itertools import chain
V = lambda *x: Vector([float(i) for i in x])
sign = lambda x: x and (1, -1)[x < 0]
PRECISION = 1.0e-5
is_x = lambda value, x: (x + PRECISION) > value > (x - PRECISION)
def is_x(value, x, si_conversion=None):
if si_conversion:
value = value * si_conversion
return (x + PRECISION) > value > (x - PRECISION)
round_to_precision = lambda x, si_conversion: round(x * si_conversion, 5) / si_conversion
round_vector_to_precision = lambda v, si_conversion: Vector([round_to_precision(i, si_conversion) for i in v])
@@ -101,7 +108,7 @@ class ShapeBuilder:
if len(segment) == 3:
ifc_segments.append(self.file.createIfcArcIndex(segment))
# NOTE: IfcIndexPolyCurve support only consequtive segments
# NOTE: IfcIndexPolyCurve support only consecutive segments
ifc_curve = self.file.createIfcIndexedPolyCurve(Points=ifc_points, Segments=ifc_segments)
return ifc_curve
@@ -534,6 +541,9 @@ class ShapeBuilder:
Position and position axes are in world space, extrusion vector in placement space defined by
position_x_axis/position_y_axis/position_z_axis
NOTE: changing position also changes the resulting geometry origin.
"""
# > profile_or_curve
# > extrusion vector - as defined in coordinate system position_x_axis+position_z_axis
@@ -575,9 +585,9 @@ class ShapeBuilder:
disk_solid = self.file.createIfcSweptDiskSolid(Directrix=path_curve, Radius=radius)
return disk_solid
def get_representation(self, context, items, representation_type:str = None):
def get_representation(self, context, items, representation_type: str = None):
"""Create IFC representation for the specified context and items.
:param context: IfcGeometricRepresentationSubContext
:param items: could be a list or single curve/IfcExtrudedAreaSolid
:param representation_type: Explicitly specified RepresentationType, defaults to `None`.
@@ -615,8 +625,11 @@ class ShapeBuilder:
# UTILITIES
def extrude_by_y_kwargs(self):
"""shortcut for `ShapeBuilder.extrude` to extrude by y axis.
it assumes you have 2d profile in xz plane and trying to extrude it by y axis"""
"""Shortcut to get kwargs for `ShapeBuilder.extrude` to extrude by Y axis.
It assumes you have 2D profile in XZ plane and trying to extrude it by Y axis.
Extruding by Y using other kwargs might break ValidExtrusionDirection."""
return {
"position_x_axis": Vector((1, 0, 0)),
"position_z_axis": Vector((0, -1, 0)),
@@ -856,6 +869,53 @@ class ShapeBuilder:
return face_set
def extrude_face_set(
self, points, magnitude: float, extrusion_vector=V(0, 0, 1).freeze(), offset=None, start_cap=True, end_cap=True
):
"""
Method to extrude by creating face sets rather than creating IfcExtrudedAreaSolid.
Useful if your representation is already using face sets and you need to avoid using SweptSolid
to assure CorrectItemsForType.
:param points: list of points, assuming they form consecutive closed polyline.
:param magnitude: extrusion magnitude
:param type: float
:param extrusion_vector: extrusion direction, by default it's extruding by Z+ axis
:param type: Vector, optional
:param offset: offset from the points
:param type: Vector, optional
:param start_cap: if True, create start cap, by default it's True
:param type: bool, optional
:param end_cap: if True, create end cap, by default it's True
:param type: bool, optional
:return: IfcPolygonalFaceSet
"""
# prevent mutating arguments, deepcopy doesn't work
start_points = [p.copy() if not offset else (p + offset) for p in points]
extrusion_offset = magnitude * extrusion_vector
end_points = [p + extrusion_offset for p in start_points]
points = start_points + end_points
faces = []
n_verts = len(start_points)
last_vert_i = n_verts - 1
for i in range(last_vert_i):
face = (i, i + 1, n_verts + i + 1, n_verts + i)
faces.append(face)
faces.append((last_vert_i, 0, n_verts + 0, n_verts + last_vert_i)) # close the loop
if end_cap:
faces.append(tuple(range(n_verts, n_verts * 2)))
if start_cap:
faces.append(tuple(reversed(range(n_verts))))
face_set = self.polygonal_face_set(points, faces)
return face_set
# TODO: move MEP to separate shape builder sub module
def mep_transition_shape(
self, start_segment, end_segment, start_length, end_length, angle=30.0, profile_offset=None
):
@@ -899,32 +959,6 @@ class ShapeBuilder:
return V(profile.Radius, profile.Radius, depth)
return None
def get_profile_faceset(points, length, offset=None):
# prevent mutating arguments, deepcopy doesn't work
start_points = [p.copy() if not offset else (p + offset) for p in points]
end_points = [p.copy() for p in start_points]
for p in end_points:
p.z += length
points = start_points + end_points
faces = []
n_verts = len(start_points)
last_vert_i = n_verts - 1
for i in range(last_vert_i):
face = (i, i + 1, n_verts + i + 1, n_verts + i)
faces.append(face)
faces.append((last_vert_i, 0, n_verts + 0, n_verts + last_vert_i)) # close the loop
# if there is offset we put a cap at the end
# otherwise at the start
if offset:
faces.append(tuple(range(n_verts, n_verts * 2)))
else:
faces.append(tuple(reversed(range(n_verts))))
face_set = self.polygonal_face_set(points, faces)
return face_set
start_profile = get_profile(start_segment)
end_profile = get_profile(end_segment)
@@ -1004,8 +1038,10 @@ class ShapeBuilder:
face = [i, next_i, next_i + n_segments, i + n_segments]
faces.append(face)
transition_items.append(get_profile_faceset(first_profile_points, start_length))
transition_items.append(get_profile_faceset(second_profile_points, end_length, end_extrusion_offset))
transition_items.append(self.extrude_face_set(first_profile_points, start_length, end_cap=False))
transition_items.append(
self.extrude_face_set(second_profile_points, end_length, end_extrusion_offset, start_cap=False)
)
first_profile_points = [p + start_offset for p in first_profile_points]
second_profile_points = [p + end_extrusion_offset for p in second_profile_points]
@@ -1032,8 +1068,10 @@ class ShapeBuilder:
else:
start_points, end_points = rect_points, circle_points
transition_items.append(get_profile_faceset(start_points, start_length))
transition_items.append(get_profile_faceset(end_points, end_length, end_extrusion_offset))
transition_items.append(self.extrude_face_set(start_points, start_length, end_cap=False))
transition_items.append(
self.extrude_face_set(end_points, end_length, end_extrusion_offset, start_cap=False)
)
# offset verts
if starting_with_circle:
@@ -1237,3 +1275,97 @@ class ShapeBuilder:
else:
angle = degrees(atan(offset.x / h))
return angle
def mep_bend_shape(
self, segment, start_length: float, end_length: float, angle: float, radius: float, profile_offset: Vector
):
"""
:param segment: IfcFlowSegment for a bend.
Note that for a bend start and end segments types should match.
:param angle: bend angle, in radians
:param type: float
:param radius: bend radius
:param type: float
:param profile_offset: offset between start and end segments in local space of start segment
used mainly to determine the bend axes and their direction.
Values themselves are replaced by the radius.
:param type: Vector
:return: tuple of Model/Body/MODEL_VIEW IfcRepresentation and transition shape data
"""
def get_profile(element):
material = ifcopenshell.util.element.get_material(element, should_skip_usage=True)
if material and material.is_a("IfcMaterialProfileSet") and len(material.MaterialProfiles) == 1:
return material.MaterialProfiles[0].Profile
def get_dim(profile, depth):
if profile.is_a("IfcRectangleProfileDef"):
return V(profile.XDim / 2, profile.YDim / 2, depth)
elif profile.is_a("IfcCircleProfileDef"):
return V(profile.Radius, profile.Radius, depth)
return None
# TODO: test with 0 radius
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(self.file)
profile = get_profile(segment)
profile_dim = get_dim(profile, start_length)
rounded_offset = round_vector_to_precision(profile_offset, si_conversion)
lateral_axis = next(i for i in range(2) if not is_x(rounded_offset[i], 0))
non_lateral_axis = 1 if lateral_axis == 0 else 0
lateral_sign = sign(profile_offset[lateral_axis])
z_sign = sign(profile_offset.z)
rep_items = []
# bend circle center
O = V(0, 0, 0)
O[lateral_axis] = (radius + profile_dim[lateral_axis]) * lateral_sign
theta = angle
def get_circle_extrusion():
# get as much segment_length segments as possible
segment_length = pi / 20
num_segments = ceil(theta / segment_length)
theta_segments = [i * segment_length for i in range(num_segments)]
if not is_x(theta_segments[-1], theta):
theta_segments.append(theta)
inner_points, outer_points = [], []
r = radius
for cur_theta in theta_segments:
cur_theta -= pi / 2
inner = V(0, 0, 0)
# fmt: off
inner.z = z_sign * cos(cur_theta) * r
inner[lateral_axis] = lateral_sign * sin(cur_theta) * r
inner_points.append(inner)
outer = V(0, 0, 0)
outer.z = z_sign * cos(cur_theta) * (r + 2 * profile_dim[lateral_axis])
outer[lateral_axis] = lateral_sign * sin(cur_theta) * (r + 2 * profile_dim[lateral_axis])
outer_points.append(outer)
# fmt: on
points = inner_points + outer_points[::-1]
points = [p + O for p in points]
offset = V(0, 0, 0)
offset[non_lateral_axis] = -profile_dim[non_lateral_axis]
extrusion_vector = V(0, 0, 0)
extrusion_vector[non_lateral_axis] = 1
extrusion = self.extrude_face_set(
points, magnitude=profile_dim[non_lateral_axis] * 2, offset=offset, extrusion_vector=extrusion_vector
)
return extrusion
rep_items.append(get_circle_extrusion())
body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW")
rep = self.get_representation(body, rep_items)
bend_data = {"start_length": start_length, "end_length": end_length, "radius": radius, "angle": degrees(theta)}
return rep, bend_data