diff --git a/src/blenderbim/blenderbim/bim/module/model/__init__.py b/src/blenderbim/blenderbim/bim/module/model/__init__.py index 9d732a9141..ee5224b157 100644 --- a/src/blenderbim/blenderbim/bim/module/model/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/model/__init__.py @@ -177,6 +177,7 @@ classes = ( roof.RemoveRoof, roof.SetGableRoofEdgeAngle, mep.MEPAddObstruction, + mep.MEPAddTransition, ) addon_keymaps = [] diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index ccbde59675..bcc45920c3 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -18,7 +18,10 @@ import bpy import math +import collections import bmesh +import re +import json import ifcopenshell import ifcopenshell.api import ifcopenshell.util.unit @@ -31,13 +34,13 @@ import blenderbim.core.type import blenderbim.core.root import blenderbim.core.geometry import blenderbim.tool as tool -from math import pi, degrees +from math import pi, degrees, radians +from copy import copy from mathutils import Vector, Matrix -import re +from ifcopenshell.util.shape_builder import ShapeBuilder from blenderbim.bim.module.model.profile import DumbProfileJoiner V = lambda *x: Vector([float(i) for i in x]) -float_is_zero = lambda f: 0.0001 >= f >= -0.0001 class RegenerateDistributionElement(bpy.types.Operator, tool.Ifc.Operator): @@ -86,7 +89,7 @@ class RegenerateDistributionElement(bpy.types.Operator, tool.Ifc.Operator): def process_branch(branch): for branch_element in branch: element = branch_element["element"] - print('processing', element) + print("processing", element) predecessor = branch_element["predecessor"] if False: # If the element does not need to be transformed, return early. return @@ -107,6 +110,9 @@ class FitFlowSegments(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): + # TODO: need to add ui for parameters: + # - obstruction cap thickness + # - start/end thickness and angle for transition selected_objs = [] selected_profiles = [] @@ -207,12 +213,7 @@ class MEPGenerator: ports = tool.System.get_ports(segment) if segment.is_a("IfcFlowSegment") and not ports: - for mat in [start_port_matrix, end_port_matrix]: - # TODO: specify PredefinedType based on the segment type - port = tool.Ifc.run("system.add_port", element=segment) - port.FlowDirection = "NOTDEFINED" - port.PredefinedType = self.get_port_predefined_type(segment) - tool.Ifc.run("geometry.edit_object_placement", product=port, matrix=mat, is_si=True) + tool.System.add_ports(obj) return # adjust current segment ports and related flow segments @@ -248,7 +249,6 @@ class MEPGenerator: ): if port_position == "start_port": if segment.is_a("IfcFlowFitting"): - profile_joiner = DumbProfileJoiner() connected_element_length = ( tool.Model.get_flow_segment_axis(connected_obj)[0] - tool.Model.get_flow_segment_axis(obj)[0] @@ -269,45 +269,103 @@ class MEPGenerator: extrusion_depth = segment_object.dimensions.z end_point = segment_object.matrix_world @ V(0, 0, extrusion_depth) segment_data = { - "start_point": start_point, - "end_point": end_point, + "start_point": start_point.copy().freeze(), + "end_point": end_point.freeze(), "ports": ports, "extrusion_depth": extrusion_depth, } for port in ports: port_local_position = V(*port.ObjectPlacement.RelativePlacement.Location.Coordinates) - if float_is_zero(port_local_position.length): + if tool.Cad.is_x(port_local_position.length, 0.0): segment_data["start_port"] = port else: segment_data["end_port"] = port return segment_data - def get_port_predefined_type(self, segment): - split_camel_case = lambda x: re.findall("[A-Z][^A-Z]*", x) - class_name = "".join(split_camel_case(segment.is_a())[1:-1]).upper() - if class_name == "CONVEYOR": - return "NOTDEFINED" - return class_name - def get_mep_element_class_name(self, element, mep_class_type): split_camel_case = lambda x: re.findall("[A-Z][^A-Z]*", x) class_name = "".join(split_camel_case(element.is_a())[:-1] + [mep_class_type]) return class_name - def get_compatible_fitting_type(self, segment, predefined_type): - """We find compatible fitting only by checking if they were - already used with that segment type before. + def get_compatible_fitting_type(self, segment_or_segments, port_or_ports, predefined_type): + """ + returns a dict of compatible fitting_type and start_port_match flag to correctly place the fitting. + + We find compatible fitting only by checking + if they were already used with that segment type before + and fitting's ports should match `port_or_ports` by PredefinedType and SystemType. + + If port from `port_or_ports` has PredefinedType/SystemType == None/NOTDEFINED then + those parameters won't be taken into account checking compatibility. There lies the problem that it won't be - able to identify the fittings that were not connected to any segments yet. + able to identify the fittings that were not yet connected to any segments yet. """ - segment_type = ifcopenshell.util.element.get_type(segment) - if not segment_type: - return None + if not isinstance(segment_or_segments, collections.abc.Iterable): + segments = [segment_or_segments] + ports = [port_or_ports] + else: + segments = segment_or_segments + ports = port_or_ports - fitting_types = tool.Ifc.get().by_type(self.get_mep_element_class_name(segment, "Fitting")) + segments_data = [] + for segment, port in zip(segments, ports, strict=True): + segment_type = ifcopenshell.util.element.get_type(segment) + # if segment doesn't have type we cannot check compatibility by available occurences + if segment_type is None: + return + segments_data.append((segment_type, port.PredefinedType, port.SystemType)) + + def are_connected_elements_compatible(segments_data, fitting_data): + # prevent arguments mutation, not using deepcopy because of the errors with ifc elements + segments_data = [copy(i) for i in segments_data] + fitting_data = [copy(i) for i in fitting_data] + not_defined_values = {"NOTDEFINED", None} + + if len(segments_data) != len(fitting_data): + return False + + def are_segments_compatible(test_segment_data, base_segment_data): + segment_type, predefined_type, system_type = test_segment_data + base_segment_type, base_predefined_type, base_system_type = base_segment_data + + if segment_type != base_segment_type: + return False + + if predefined_type not in not_defined_values and predefined_type != base_predefined_type: + return False + + if system_type not in not_defined_values and system_type != base_system_type: + return False + + return True + + # NOTE: I have a feeling that there are cases where order + # in which we're checking the segments is important + # but I couldn't pin it down exact cases + for test_segment_data in fitting_data[:]: + for base_segment_data in segments_data: + if not are_segments_compatible(test_segment_data, base_segment_data): + continue + segments_data.remove(test_segment_data) + + # all segments were sorted + return len(segments_data) == 0 + + def pack_return_data(fitting_type, ports, segments_data): + for port in ports: + port_local_position = V(*port.ObjectPlacement.RelativePlacement.Location.Coordinates) + if tool.Cad.is_x(port_local_position.length, 0.0): + start_port = port + break + connected_port = tool.System.get_connected_port(start_port) + connected_element = tool.System.get_port_relating_element(connected_port) + element_type = ifcopenshell.util.element.get_type(connected_element) + return {"fitting_type": fitting_type, "start_port_match": element_type == segments_data[0][0]} + + fitting_types = tool.Ifc.get().by_type(self.get_mep_element_class_name(segments[0], "FittingType")) for fitting_type in fitting_types: if fitting_type.PredefinedType != predefined_type: continue @@ -315,13 +373,24 @@ class MEPGenerator: if not fittings: continue fitting = fittings[0] - elements = set( - ifcopenshell.util.system.get_connected_to(fitting) - + ifcopenshell.util.system.get_connected_from(fitting) - ) - for element in elements: - if element.IsTypedBy and element.IsTypedBy[0].RelatingType == segment_type: - return fitting_type + + ports = ifcopenshell.util.system.get_ports(fitting) + fitting_data = [] + fitting_connected_to_none_type = False + for port in ports: + connected_port = tool.System.get_connected_port(port) + connected_element = tool.System.get_port_relating_element(connected_port) + element_type = ifcopenshell.util.element.get_type(connected_element) + if element_type is None: + fitting_connected_to_none_type = True + break + fitting_data.append((element_type, port.PredefinedType, port.SystemType)) + + if fitting_connected_to_none_type: + continue + + if are_connected_elements_compatible(segments_data, fitting_data): + return pack_return_data(fitting_type, ports, segments_data) def create_obstruction_type(self, segment): # code is very similar to "bim.add_type" @@ -333,7 +402,8 @@ class MEPGenerator: ifc_file = tool.Ifc.get() body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") - obj = bpy.data.objects.new("Fitting", None) + obj = bpy.data.objects.new("Obstruction", None) + # TODO: OBSTRUCTION predefined type is available only for IfcDuctFitting and IfcPipeFitting element = blenderbim.core.root.assign_class( tool.Ifc, tool.Collector, @@ -377,7 +447,8 @@ class MEPGenerator: segment_obj = tool.Ifc.get_object(segment) segment_matrix = segment_obj.matrix_world segment_rotation = segment_matrix.to_quaternion() - obstruction_type = self.get_compatible_fitting_type(segment, "OBSTRUCTION") + fitting_data = self.get_compatible_fitting_type(segment, related_port, "OBSTRUCTION") + obstruction_type = fitting_data["fitting_type"] if fitting_data else None if not obstruction_type: obstruction_type = self.create_obstruction_type(segment) @@ -389,17 +460,11 @@ class MEPGenerator: obstruction_obj.matrix_world = segment_matrix profile_joiner.set_depth(obstruction_obj, length) - obstruction = tool.Ifc.get_entity(obstruction_obj) - # TODO: specify PredefinedType based on the segment type - obstruction_port = tool.Ifc.run("system.add_port", element=obstruction) - obstruction_port.PredefinedType = self.get_port_predefined_type(obstruction) - port_local_position = Matrix.Translation((0, 0, length)) if at_segment_start else Matrix() - tool.Ifc.run( - "geometry.edit_object_placement", - product=obstruction_port, - matrix=segment_matrix @ port_local_position, - is_si=True, - ) + obstruction_port = tool.System.add_ports( + obstruction_obj, + add_start_port=not at_segment_start, + add_end_port=at_segment_start, + )[0] # change segment length new_segment_length = segment_data["extrusion_depth"] - length @@ -411,6 +476,7 @@ class MEPGenerator: obstruction_obj.location += segment_rotation @ V(0, 0, new_segment_length) tool.Ifc.run("system.connect_port", port1=related_port, port2=obstruction_port, direction="NOTDEFINED") + obstruction = tool.Ifc.get_entity(obstruction_obj) return obstruction, None @@ -449,3 +515,153 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator): return {"CANCELLED"} return {"FINISHED"} + + +class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.mep_add_transition" + bl_label = "Add Transition" + bl_description = ( + "Adds transition 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="Transition start length in SI units", default=0.1, subtype="DISTANCE" + ) + end_length: bpy.props.FloatProperty( + name="End Length", description="Transition 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) + + 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) + + 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 transition") + return {"CANCELLED"} + + else: + self.report({"ERROR"}, f"Two IFC elements should be provided for the transition") + return {"CANCELLED"} + + # TODO: support IfcFlowTerminal + def is_mep(element): + return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting") + + if not is_mep(start_element) or not is_mep(end_element): + self.report( + {"ERROR"}, + f"Failed to add transition - some object is not a MEP element: {start_element.is_a()}, {end_element.is_a()}.", + ) + return {"CANCELLED"} + + start_axis = tool.Model.get_flow_segment_axis(start_object) + end_axis = tool.Model.get_flow_segment_axis(end_object) + + # TODO: support cases when segments are partially or completely overlapping each other + if not tool.Cad.are_edges_collinear(start_axis, end_axis): + self.report({"ERROR"}, f"Failed to add transition - non collinear segments are not yet supported.") + return {"CANCELLED"} + + 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"], + } + + start_point, end_point = tool.Cad.closest_points( + (start_segment_data["start_point"], start_segment_data["end_point"]), + (end_segment_data["start_point"], end_segment_data["end_point"]), + ) + transition_dir = (end_point - start_point).normalized() + start_port = points_ports_map[start_point] + end_port = points_ports_map[end_point] + + # add transition representation + builder = ShapeBuilder(ifc_file) + rep, transition_data = builder.mep_transition_shape( + start_element, end_element, self.start_length / si_conversion, self.end_length / si_conversion + ) + + if not rep: + self.report({"ERROR"}, f"Failed to add transition - this kind of profiles is not yet supported.") + return {"CANCELLED"} + + middle_point = (start_point + end_point) / 2 + full_transition_length = transition_data["full_transition_length"] * si_conversion + start_segment_extend_point = middle_point - transition_dir * full_transition_length / 2 + end_segment_extend_point = middle_point + transition_dir * full_transition_length / 2 + DumbProfileJoiner().join_E(start_object, start_segment_extend_point) + DumbProfileJoiner().join_E(end_object, end_segment_extend_point) + + fitting_data = MEPGenerator().get_compatible_fitting_type( + [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 not transition_type: + mesh = bpy.data.meshes.new("Transition") + obj = bpy.data.objects.new("Transition", mesh) + transition_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="TRANSITION", + 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=transition_type, name="BBIM_Fitting") + ifcopenshell.api.run( + "pset.edit_pset", + tool.Ifc.get(), + pset=pset, + properties={"Data": json.dumps(transition_data, default=list)}, + ) + + # NOTE: at this point we loose current blender objects selection + bpy.ops.bim.add_constr_type_instance(relating_type_id=transition_type.id()) + transition_obj = bpy.context.active_object + + # adjust transition segment rotation and location + transition_obj.matrix_world = start_object.matrix_world + context.view_layer.update() + transition_obj_dir = tool.Cad.get_edge_direction(tool.Model.get_flow_segment_axis(transition_obj)) + direction_match = tool.Cad.are_vectors_equal(transition_obj_dir, transition_dir) + + # if there are no mismatches or everything matches up we don't need to flip the transition + if start_port_match != direction_match: + transition_obj.matrix_world = start_object.matrix_world @ Matrix.Rotation(radians(180), 4, "X") + transition_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(transition_obj) + 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") + + return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/tool/cad.py b/src/blenderbim/blenderbim/tool/cad.py index 9ed290e59a..6a28231b74 100644 --- a/src/blenderbim/blenderbim/tool/cad.py +++ b/src/blenderbim/blenderbim/tool/cad.py @@ -91,10 +91,14 @@ class Cad: tolerance = VTX_PRECISION if isinstance(x, (list, tuple)): for y in x: - if value > (y - tolerance) and value < (y + tolerance): + if (y + tolerance) > value > (y - tolerance): return True return False - return value > (x - tolerance) and value < (x + tolerance) + return (x + tolerance) > value > (x - tolerance) + + @classmethod + def are_vectors_equal(cls, v1: Vector, v2: Vector): + return cls.is_x((v2 - v1).length, 0) @classmethod def intersect_edges(cls, edge1, edge2): @@ -227,6 +231,48 @@ class Cad: res = [cls.is_point_on_edge(pt, edge) for edge in [edges[:2], edges[2:]]] return len([i for i in res if i]) + @classmethod + def get_edge_direction(cls, edge): + return (edge[1] - edge[0]).normalized() + + @classmethod + def are_edges_collinear(cls, edge1, edge2): + def is_point_on_line(p, edge): + a1, a2 = edge + # comparing slopes between PA1 and A2A1 + # using cross multiplication to avoid division by zero + return cls.is_x((p.y - a1.y) * (a2.x - a1.x), (a2.y - a1.y) * (p.x - a1.x)) + + edge1_dir = edge1[1] - edge1[0] + edge2_dir = edge2[1] - edge2[0] + + if cls.is_x(edge1_dir.cross(edge2_dir).length_squared, 0): # check they are parallel + if is_point_on_line(edge1[0], edge2) or is_point_on_line(edge1[1], edge2): + return True + return False + + @classmethod + def closest_points(cls, edge1, edge2): + """ + + closest end points between `edge1` and `edge2` assuming `edge1` and `edge2` are collinear. + + < 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 + points1_values = [(p, p.dot(direction)) for p in edge1] + points2_values = [(p, p.dot(direction)) for p in edge2] + + # Sort the projections for both edges + sorted_points1 = sorted(points1_values, key=lambda el: el[1]) + sorted_points2 = sorted(points2_values, key=lambda el: el[1]) + + # The closest points will be the last point of the first edge and the first point of the second edge + return sorted_points1[-1][0], sorted_points2[0][0] + @classmethod def find_intersecting_edges(cls, bm, pt, idx1, idx2): """ diff --git a/src/blenderbim/blenderbim/tool/system.py b/src/blenderbim/blenderbim/tool/system.py index e6aa4ecabc..5796fcafa0 100644 --- a/src/blenderbim/blenderbim/tool/system.py +++ b/src/blenderbim/blenderbim/tool/system.py @@ -21,9 +21,35 @@ import ifcopenshell.util.system import blenderbim.core.tool import blenderbim.tool as tool from blenderbim.bim import import_ifc +import re +from mathutils import Matrix class System(blenderbim.core.tool.System): + @classmethod + def add_ports(cls, obj, add_start_port=True, add_end_port=True): + def add_port(mep_element, matrix): + port = tool.Ifc.run("system.add_port", element=mep_element) + port.FlowDirection = "NOTDEFINED" + port.PredefinedType = tool.System.get_port_predefined_type(mep_element) + tool.Ifc.run("geometry.edit_object_placement", product=port, matrix=matrix, is_si=True) + return port + + # make sure obj.dimensions and .matrix_world has valid data + bpy.context.view_layer.update() + # need to make sure .ObjectPlacement is also updated when we're going to add ports + if tool.Ifc.is_moved(obj): + 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 + ports = [] + if add_start_port: + ports.append(add_port(mep_element, obj.matrix_world @ Matrix())) + if add_end_port: + ports.append(add_port(mep_element, obj.matrix_world @ Matrix.Translation((0, 0, length)))) + return ports + @classmethod def create_empty_at_cursor_with_element_orientation(cls, element): element_obj = tool.Ifc.get_object(element) @@ -68,6 +94,14 @@ class System(blenderbim.core.tool.System): def get_port_relating_element(cls, port): return port.Nests[0].RelatingObject + @classmethod + def get_port_predefined_type(cls, mep_element): + split_camel_case = lambda x: re.findall("[A-Z][^A-Z]*", x) + class_name = "".join(split_camel_case(mep_element.is_a())[1:-1]).upper() + if class_name == "CONVEYOR": + return "NOTDEFINED" + return class_name + @classmethod def import_system_attributes(cls, system): props = bpy.context.scene.BIMSystemProperties diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index fdcd02815c..6cf52d0581 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -19,7 +19,7 @@ import collections import ifcopenshell import ifcopenshell.api -from math import cos, sin, pi +from math import cos, sin, pi, tan, radians from mathutils import Vector, Matrix from itertools import chain @@ -539,7 +539,7 @@ class ShapeBuilder: "Ref: https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcPositiveLengthMeasure.htm#8.11.2.71.3-Formal-representation" ) - if profile_or_curve.is_a() not in ("IfcArbitraryClosedProfileDef", "IfcArbitraryProfileDefWithVoids"): + if not profile_or_curve.is_a("IfcProfileDef"): profile_or_curve = self.profile(profile_or_curve) if position_y_axis: @@ -579,6 +579,8 @@ class ShapeBuilder: representation_type = "AdvancedSweptSolid" elif "IfcExtrudedAreaSolid" in item_types: representation_type = "SweptSolid" + elif items[0].is_a("IfcTessellatedItem"): + representation_type = "Tessellation" elif items[0].is_a("IfcCurve") and items[0].Dim == 3: representation_type = "Curve3D" else: @@ -746,8 +748,10 @@ class ShapeBuilder: ifc_curve = self.file.createIfcIndexedPolyCurve(Points=ifc_points, Segments=ifc_segments) return (points, segments, ifc_curve) - - def create_z_profile_lips_curve(self, FirstFlangeWidth, SecondFlangeWidth, Depth, Girth, WallThickness, FilletRadius): + + def create_z_profile_lips_curve( + self, FirstFlangeWidth, SecondFlangeWidth, Depth, Girth, WallThickness, FilletRadius + ): x1 = FirstFlangeWidth x2 = SecondFlangeWidth y = Depth / 2 @@ -770,20 +774,21 @@ class ShapeBuilder: (-x1+t, -y+t), (-t/2, -y+t) ) - # fmt: on # option for no additional thickness in outer radius: # points, segments, ifc_curve = create_curve_from_coords( # coords, fillets = (0, 1, 4, 5, 6, 7, 10, 11), fillet_radius=r, closed=True, ifc_file=ifc_file # ) - points, segments, ifc_curve = self.get_simple_2dcurve_data(coords, + points, segments, ifc_curve = self.get_simple_2dcurve_data( + coords, fillets = (0, 1, 4, 5, 6, 7, 10, 11), fillet_radius=(r+t, r+t, r, r, r+t, r+t, r, r), closed=True, create_ifc_curve=True) + # fmt: on return ifc_curve - + def create_transition_arc_ifc(self, width, height, create_ifc_curve=False): # create an arc in the rectangle with specified width and height # if it's not possible to make a complete arc @@ -814,4 +819,114 @@ class ShapeBuilder: points, segments, transition_arc = self.get_simple_2dcurve_data( curve_coords, fillets, fillet_radius, closed=False, create_ifc_curve=create_ifc_curve ) - return points, segments, transition_arc \ No newline at end of file + return points, segments, transition_arc + + def polygonal_face_set(self, points, faces): + """ + > `points` - list of points + + > `faces` - list of faces consisted of point indices (points indices starting from 0) + + < IfcPolygonalFaceSet + """ + + ifc_points = self.file.createIfcCartesianPointList3D(points) + ifc_faces = [] + for face in faces: + face = [i + 1 for i in face] + ifc_faces.append(self.file.createIfcIndexedPolygonalFace(face)) + + face_set = self.file.createIfcPolygonalFaceSet(Coordinates=ifc_points, Faces=ifc_faces) + + return face_set + + def mep_transition_shape(self, start_segment, end_segment, start_length, end_length, angle=30.0): + """ + returns tuple of Model/Body/MODEL_VIEW IfcRepresentation and transition shape data + """ + # good default values from angle = 30/60 deg + # 30 degree angle will result in 75 degrees on the transition (= 90 - α/2) - https://i.imgur.com/tcoYDWu.png + + # TODO: get rid of reliance on profiles + 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 + + start_profile = get_profile(start_segment) + end_profile = get_profile(end_segment) + + # TODO: support more profiles + if not start_profile.is_a("IfcRectangleProfileDef") or not end_profile.is_a("IfcRectangleProfileDef"): + # Non rectangular profiles are not yet supported + return None, None + + start_half_dim = V(start_profile.XDim / 2, start_profile.YDim / 2, start_length) + end_half_dim = V(end_profile.XDim / 2, end_profile.YDim / 2, end_length) + + transition_items = [] + end_extrusion_offset = V(0, 0, start_length) + + def get_transition_legth(start_half_dim, end_half_dim, angle): + diff = start_half_dim.xy - end_half_dim.xy + diff = Vector([abs(i) for i in diff]) + c = diff.x * tan(radians(90 - angle / 2)) + a = diff.y + b = (c**2 - a**2) ** 0.5 + return b + + transition_length = get_transition_legth(start_half_dim, end_half_dim, angle) + faces = [] + if transition_length != 0: + end_extrusion_offset.z += transition_length + + faces += [(3, 4, 7, 0), (11, 8, 15, 12), (3, 11, 12, 4), (7, 15, 8, 0)] + + # NOTE: clockwise order for correct face orientation + faces += [ + # start extrusion + (0, 1, 2, 3), + (8, 11, 10, 9), + (0, 8, 9, 1), + (1, 9, 10, 2), + (2, 10, 11, 3), + # end extrusion + (4, 5, 6, 7), + (12, 15, 14, 13), + (4, 12, 13, 5), + (5, 13, 14, 6), + (6, 14, 15, 7), + ] + points = [ + start_half_dim * V(-1, -1, 1), + start_half_dim * V(-1, -1, 0), + start_half_dim * V(1, -1, 0), + start_half_dim * V(1, -1, 1), + end_half_dim * V(1, -1, 0) + end_extrusion_offset, + end_half_dim * V(1, -1, 1) + end_extrusion_offset, + end_half_dim * V(-1, -1, 1) + end_extrusion_offset, + end_half_dim * V(-1, -1, 0) + end_extrusion_offset, + start_half_dim * V(-1, 1, 1), + start_half_dim * V(-1, 1, 0), + start_half_dim * V(1, 1, 0), + start_half_dim * V(1, 1, 1), + end_half_dim * V(1, 1, 0) + end_extrusion_offset, + end_half_dim * V(1, 1, 1) + end_extrusion_offset, + end_half_dim * V(-1, 1, 1) + end_extrusion_offset, + end_half_dim * V(-1, 1, 0) + end_extrusion_offset, + ] + + face_set = self.polygonal_face_set(points, faces) + transition_items.append(face_set) + + body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW") + representation = self.get_representation(body, transition_items, "Tesselation") + transition_data = { + "start_length": start_length, + "end_length": end_length, + "angle": angle, + "transition_length": transition_length, + "full_transition_length": start_length + transition_length + end_length, + } + + return representation, transition_data