From 86b63242d8f95b8ad58f55f086601ebc3df8c7c1 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 4 Aug 2023 14:46:48 +0500 Subject: [PATCH] bim.add_transition Added simple operator to add transition between two mep segments (now only rectangular collinear segments are supported). It also reuses the transition type that was previously used to connect segments of the same type. Demonstration - https://imgur.com/a/c1AOxj1 --- .../blenderbim/bim/module/model/__init__.py | 1 + .../blenderbim/bim/module/model/mep.py | 314 +++++++++++++++--- src/blenderbim/blenderbim/tool/cad.py | 50 ++- src/blenderbim/blenderbim/tool/system.py | 34 ++ .../ifcopenshell/util/shape_builder.py | 131 +++++++- 5 files changed, 471 insertions(+), 59 deletions(-) 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